ai-interactive-feedback 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # AI Interactive Feedback
2
+
3
+ AI 交互式反馈 MCP 工具,通过浏览器 GUI 收集用户反馈。
4
+
5
+ ## 特性
6
+
7
+ - 🌐 浏览器作为 GUI,无需额外依赖
8
+ - 🖼️ 支持粘贴图片
9
+ - ✅ 预定义选项快速选择
10
+ - 🌙 自动适应系统主题
11
+ - ⌨️ 快捷键支持
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ npm install -g ai-interactive-feedback
17
+ ```
18
+
19
+ ## MCP 配置
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "ai-interactive-feedback": {
25
+ "command": "npx",
26
+ "args": ["-y", "ai-interactive-feedback"],
27
+ "autoApprove": ["whale_interactive_feedback"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ## 工具
34
+
35
+ ### whale_interactive_feedback
36
+
37
+ 请求用户交互式反馈。
38
+
39
+ 参数:
40
+ - `message` (必需): AI 完成的工作摘要
41
+ - `full_response` (可选): 完整详细内容
42
+ - `predefined_options` (可选): 预定义选项列表
43
+
44
+ ## 快捷键
45
+
46
+ | 快捷键 | 操作 |
47
+ |--------|------|
48
+ | Ctrl/⌘ + Enter | 提交 |
49
+ | Ctrl/⌘ + V | 粘贴图片 |
50
+ | Esc | 取消 |
51
+
52
+ ## 环境变量
53
+
54
+ - `AI_FEEDBACK_THEME`: 主题设置 (`auto`, `light`, `dark`)
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { createServer } from "http";
6
+ import { exec } from "child_process";
7
+ import { platform } from "os";
8
+ import { generateHtml } from "./template.js";
9
+ // 常量
10
+ const TIMEOUT_MS = 1800000; // 30 分钟
11
+ const THEME = process.env.AI_FEEDBACK_THEME || "auto";
12
+ // 工具函数
13
+ function openBrowser(url) {
14
+ const os = platform();
15
+ if (os === "darwin") {
16
+ exec(`open "${url}"`);
17
+ }
18
+ else if (os === "win32") {
19
+ exec(`start "" "${url}"`);
20
+ }
21
+ else {
22
+ exec(`xdg-open "${url}"`);
23
+ }
24
+ }
25
+ function formatResponse(text, images = []) {
26
+ const content = [{ type: "text", text }];
27
+ for (const img of images) {
28
+ content.push({ type: "image", data: img.data, mimeType: img.mimeType });
29
+ }
30
+ return { content };
31
+ }
32
+ // HTTP 服务器获取用户反馈
33
+ function getUserFeedback(message, fullResponse, predefinedOptions) {
34
+ return new Promise((resolve) => {
35
+ const html = generateHtml(message, fullResponse, predefinedOptions, THEME);
36
+ let resolved = false;
37
+ const done = (result) => {
38
+ if (resolved)
39
+ return;
40
+ resolved = true;
41
+ resolve(result);
42
+ httpServer.close();
43
+ };
44
+ const httpServer = createServer((req, res) => {
45
+ // CORS headers
46
+ res.setHeader("Access-Control-Allow-Origin", "*");
47
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
48
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
49
+ if (req.method === "OPTIONS") {
50
+ res.writeHead(200);
51
+ res.end();
52
+ return;
53
+ }
54
+ if (req.method === "GET" && req.url === "/") {
55
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
56
+ res.end(html);
57
+ }
58
+ else if (req.method === "POST" && req.url === "/submit") {
59
+ let body = "";
60
+ req.on("data", (chunk) => (body += chunk));
61
+ req.on("end", () => {
62
+ res.writeHead(200, { "Content-Type": "application/json" });
63
+ res.end('{"ok":true}');
64
+ try {
65
+ const data = JSON.parse(body);
66
+ if (data.cancelled) {
67
+ done({
68
+ userInput: null,
69
+ selectedOptions: [],
70
+ images: [],
71
+ fileReferences: [],
72
+ cancelled: true,
73
+ });
74
+ }
75
+ else {
76
+ done({
77
+ userInput: data.userInput || null,
78
+ selectedOptions: data.selectedOptions || [],
79
+ images: data.images || [],
80
+ fileReferences: data.fileReferences || [],
81
+ cancelled: false,
82
+ });
83
+ }
84
+ }
85
+ catch {
86
+ done({
87
+ userInput: null,
88
+ selectedOptions: [],
89
+ images: [],
90
+ fileReferences: [],
91
+ cancelled: true,
92
+ });
93
+ }
94
+ });
95
+ }
96
+ else {
97
+ res.writeHead(404);
98
+ res.end();
99
+ }
100
+ });
101
+ httpServer.listen(0, "127.0.0.1", () => {
102
+ const addr = httpServer.address();
103
+ if (addr && typeof addr === "object") {
104
+ const url = `http://127.0.0.1:${addr.port}`;
105
+ console.error(`[AI-Feedback] 打开浏览器: ${url}`);
106
+ openBrowser(url);
107
+ }
108
+ });
109
+ // 超时自动取消
110
+ setTimeout(() => {
111
+ done({
112
+ userInput: null,
113
+ selectedOptions: [],
114
+ images: [],
115
+ fileReferences: [],
116
+ cancelled: true,
117
+ });
118
+ }, TIMEOUT_MS);
119
+ });
120
+ }
121
+ // MCP 服务器配置
122
+ const server = new Server({ name: "ai-interactive-feedback", version: "1.0.0" }, { capabilities: { tools: {} } });
123
+ const TOOLS = [
124
+ {
125
+ name: "whale_interactive_feedback",
126
+ description: "Request interactive feedback from the user. Opens a popup for the user to review AI's work and provide feedback, select options, or attach images.",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: {
130
+ message: {
131
+ type: "string",
132
+ description: "Summary of the changes or work done by the AI that needs user review",
133
+ },
134
+ full_response: {
135
+ type: "string",
136
+ description: "Full detailed content (optional, shown in expandable section)",
137
+ },
138
+ predefined_options: {
139
+ type: "array",
140
+ items: { type: "string" },
141
+ description: "List of predefined options for the user to choose from",
142
+ },
143
+ },
144
+ required: ["message"],
145
+ },
146
+ },
147
+ ];
148
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
149
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
150
+ const { name, arguments: args } = request.params;
151
+ const params = args;
152
+ if (name === "whale_interactive_feedback") {
153
+ const message = params.message || "";
154
+ const fullResponse = params.full_response || null;
155
+ const predefinedOptions = params.predefined_options || [];
156
+ if (!message.trim()) {
157
+ return formatResponse("❌ 错误:message 参数不能为空");
158
+ }
159
+ console.error(`[AI-Feedback] 请求用户反馈: ${message.substring(0, 50)}...`);
160
+ const feedback = await getUserFeedback(message, fullResponse, predefinedOptions);
161
+ if (feedback.cancelled) {
162
+ return formatResponse("[User cancelled or provided no feedback]");
163
+ }
164
+ // 格式化结果
165
+ const parts = [];
166
+ if (feedback.selectedOptions.length > 0) {
167
+ parts.push(`**Selected Options:** ${feedback.selectedOptions.join(", ")}`);
168
+ }
169
+ if (feedback.userInput && feedback.userInput.trim()) {
170
+ parts.push(`**User Feedback:**\n${feedback.userInput}`);
171
+ }
172
+ if (feedback.images.length > 0) {
173
+ parts.push(`**Attached Images:** ${feedback.images.length} image(s)`);
174
+ }
175
+ if (feedback.fileReferences.length > 0) {
176
+ const fileList = feedback.fileReferences
177
+ .map((f) => `${f.isDirectory ? "📁" : "📄"} ${f.path}`)
178
+ .join("\n");
179
+ parts.push(`**Attached Files:**\n${fileList}`);
180
+ }
181
+ if (parts.length === 0) {
182
+ return formatResponse("No feedback provided by user.");
183
+ }
184
+ return formatResponse(parts.join("\n\n"), feedback.images);
185
+ }
186
+ return formatResponse(`未知工具: ${name}`);
187
+ });
188
+ // 启动
189
+ async function main() {
190
+ const transport = new StdioServerTransport();
191
+ await server.connect(transport);
192
+ console.error("[AI-Feedback] MCP 服务器已启动");
193
+ }
194
+ main().catch(console.error);
@@ -0,0 +1,3 @@
1
+ export declare const CSS = "\n:root {\n --bg-primary: #f8fafc;\n --bg-secondary: #f1f5f9;\n --bg-card: #ffffff;\n --bg-glass: rgba(255, 255, 255, 0.8);\n --bg-hover: #f1f5f9;\n --text-primary: #1e293b;\n --text-secondary: #64748b;\n --text-muted: #94a3b8;\n --border-color: #e2e8f0;\n --border-subtle: #f1f5f9;\n --accent-color: #3b82f6;\n --accent-light: rgba(59, 130, 246, 0.1);\n --accent-hover: #2563eb;\n --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);\n --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);\n --blur-amount: 20px;\n --scrollbar-thumb: #cbd5e1;\n --scrollbar-thumb-hover: #94a3b8;\n}\n\n[data-theme=\"dark\"] {\n --bg-primary: #0f172a;\n --bg-secondary: #1e293b;\n --bg-card: #1e293b;\n --bg-glass: rgba(30, 41, 59, 0.8);\n --bg-hover: #334155;\n --text-primary: #f1f5f9;\n --text-secondary: #94a3b8;\n --text-muted: #64748b;\n --border-color: #334155;\n --border-subtle: #1e293b;\n --accent-color: #60a5fa;\n --accent-light: rgba(96, 165, 250, 0.15);\n --accent-hover: #3b82f6;\n --scrollbar-thumb: #475569;\n --scrollbar-thumb-hover: #64748b;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n background: var(--bg-primary);\n color: var(--text-primary);\n min-height: 100vh;\n line-height: 1.6;\n}\n\n.app-container {\n display: flex;\n flex-direction: column;\n height: 100vh;\n overflow: hidden;\n}\n\n/* \u4E3B\u5E03\u5C40 */\n.main-layout {\n flex: 1;\n display: flex;\n flex-direction: column;\n padding: 16px;\n gap: 16px;\n overflow: hidden;\n}\n\n/* \u5DE6\u4FA7\u9762\u677F - \u663E\u793A\u5185\u5BB9 + \u9009\u9879 */\n.left-panel {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 12px;\n min-height: 0;\n overflow: hidden;\n}\n\n/* \u663E\u793A\u533A\u57DF */\n.display-area {\n flex: 1;\n min-height: 200px;\n background: var(--bg-card);\n border-radius: 12px;\n border: 1px solid var(--border-color);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.display-header {\n padding: 12px 16px;\n border-bottom: 1px solid var(--border-subtle);\n font-size: 12px;\n font-weight: 600;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n background: var(--bg-secondary);\n}\n\n.display-content {\n flex: 1;\n padding: 16px;\n overflow: auto;\n font-size: 14px;\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.display-content::-webkit-scrollbar {\n width: 6px;\n}\n\n.display-content::-webkit-scrollbar-thumb {\n background: var(--scrollbar-thumb);\n border-radius: 3px;\n}\n\n.display-content::-webkit-scrollbar-thumb:hover {\n background: var(--scrollbar-thumb-hover);\n}\n\n/* \u9009\u9879\u533A\u57DF */\n.options-area {\n flex-shrink: 0;\n max-height: 200px;\n overflow: auto;\n}\n\n.options-list {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.option-item {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 14px;\n background: var(--bg-card);\n border: 1px solid var(--border-color);\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s ease;\n user-select: none;\n}\n\n.option-item:hover {\n background: var(--bg-hover);\n border-color: var(--accent-color);\n}\n\n.option-item.selected {\n background: var(--accent-light);\n border-color: var(--accent-color);\n}\n\n.option-checkbox {\n width: 18px;\n height: 18px;\n border: 2px solid var(--border-color);\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all 0.2s ease;\n flex-shrink: 0;\n}\n\n.option-item.selected .option-checkbox {\n background: var(--accent-color);\n border-color: var(--accent-color);\n}\n\n.option-checkbox svg {\n width: 12px;\n height: 12px;\n color: white;\n opacity: 0;\n}\n\n.option-item.selected .option-checkbox svg {\n opacity: 1;\n}\n\n.option-text {\n font-size: 14px;\n color: var(--text-primary);\n}\n\n/* \u53F3\u4FA7\u9762\u677F - \u8F93\u5165\u533A\u57DF */\n.right-panel {\n flex-shrink: 0;\n height: 180px;\n display: flex;\n flex-direction: column;\n}\n\n.input-wrapper {\n flex: 1;\n display: flex;\n flex-direction: column;\n background: var(--bg-card);\n border-radius: 12px;\n border: 1px solid var(--border-color);\n overflow: hidden;\n transition: border-color 0.2s ease, box-shadow 0.2s ease;\n}\n\n.input-wrapper:focus-within {\n border-color: var(--accent-color);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n\n.feedback-input {\n flex: 1;\n width: 100%;\n padding: 14px 16px;\n border: none;\n background: transparent;\n color: var(--text-primary);\n font-size: 14px;\n line-height: 1.6;\n resize: none;\n outline: none;\n font-family: inherit;\n}\n\n.feedback-input::placeholder {\n color: var(--text-muted);\n}\n\n/* \u56FE\u7247\u9884\u89C8\u533A\u57DF */\n.images-preview {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n padding: 8px 12px;\n border-top: 1px solid var(--border-subtle);\n background: var(--bg-secondary);\n}\n\n.images-preview:empty {\n display: none;\n}\n\n.image-item {\n position: relative;\n width: 60px;\n height: 60px;\n border-radius: 8px;\n overflow: hidden;\n border: 1px solid var(--border-color);\n}\n\n.image-item img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.image-item .remove-btn {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 20px;\n height: 20px;\n background: #ef4444;\n color: white;\n border: 2px solid white;\n border-radius: 50%;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 14px;\n line-height: 1;\n opacity: 0;\n transition: opacity 0.2s;\n}\n\n.image-item:hover .remove-btn {\n opacity: 1;\n}\n\n/* \u5E95\u90E8\u5DE5\u5177\u680F */\n.bottom-toolbar {\n flex-shrink: 0;\n display: flex;\n justify-content: flex-end;\n align-items: center;\n gap: 12px;\n padding: 12px 16px;\n background: var(--bg-glass);\n backdrop-filter: blur(var(--blur-amount));\n border-top: 1px solid var(--border-color);\n}\n\n.helper-text {\n flex: 1;\n font-size: 12px;\n color: var(--text-muted);\n}\n\n.kbd {\n display: inline-block;\n padding: 2px 6px;\n background: var(--bg-secondary);\n border: 1px solid var(--border-color);\n border-radius: 4px;\n font-family: monospace;\n font-size: 11px;\n}\n\n.cancel-btn {\n padding: 10px 20px;\n background: transparent;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n color: var(--text-secondary);\n font-size: 14px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.2s ease;\n}\n\n.cancel-btn:hover {\n background: var(--bg-hover);\n color: var(--text-primary);\n}\n\n.submit-btn {\n padding: 10px 24px;\n background: var(--accent-color);\n border: none;\n border-radius: 8px;\n color: white;\n font-size: 14px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.2s ease;\n box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);\n}\n\n.submit-btn:hover {\n background: var(--accent-hover);\n transform: translateY(-1px);\n box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);\n}\n\n.submit-btn:active {\n transform: translateY(0);\n}\n\n/* \u6210\u529F\u9875\u9762 */\n.success-page {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100vh;\n gap: 16px;\n animation: fadeIn 0.4s ease;\n}\n\n.success-icon {\n font-size: 64px;\n}\n\n.success-title {\n font-size: 24px;\n font-weight: 600;\n color: var(--text-primary);\n}\n\n.success-subtitle {\n font-size: 14px;\n color: var(--text-secondary);\n}\n\n@keyframes fadeIn {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n/* \u54CD\u5E94\u5F0F */\n@media (min-width: 768px) {\n .main-layout {\n flex-direction: row;\n }\n \n .left-panel {\n flex: 1;\n }\n \n .right-panel {\n width: 350px;\n height: auto;\n }\n}\n";
2
+ export declare const SCRIPT = "\nconst state = {\n selectedOptions: new Set(),\n images: [],\n userInput: ''\n};\n\nconst textarea = document.getElementById('feedbackInput');\nconst imagesPreview = document.getElementById('imagesPreview');\n\n// \u9009\u9879\u5207\u6362\nfunction toggleOption(index) {\n const item = document.querySelectorAll('.option-item')[index];\n if (state.selectedOptions.has(index)) {\n state.selectedOptions.delete(index);\n item.classList.remove('selected');\n } else {\n state.selectedOptions.add(index);\n item.classList.add('selected');\n }\n}\n\n// \u6E32\u67D3\u56FE\u7247\u9884\u89C8\nfunction renderImages() {\n imagesPreview.innerHTML = state.images.map((img, i) => \n `<div class=\"image-item\">\n <img src=\"${img.dataUrl}\" alt=\"preview\">\n <button class=\"remove-btn\" onclick=\"removeImage(${i})\">\u00D7</button>\n </div>`\n ).join('');\n}\n\n// \u79FB\u9664\u56FE\u7247\nfunction removeImage(index) {\n state.images.splice(index, 1);\n renderImages();\n}\n\n// \u7C98\u8D34\u56FE\u7247\ndocument.addEventListener('paste', (e) => {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n for (const item of items) {\n if (item.type.startsWith('image/')) {\n e.preventDefault();\n const file = item.getAsFile();\n if (file) {\n const reader = new FileReader();\n reader.onload = (ev) => {\n const dataUrl = ev.target?.result;\n if (typeof dataUrl === 'string') {\n const base64 = dataUrl.split(',')[1];\n state.images.push({\n dataUrl,\n data: base64,\n mimeType: item.type\n });\n renderImages();\n }\n };\n reader.readAsDataURL(file);\n }\n break;\n }\n }\n});\n\n// \u63D0\u4EA4\nfunction submit(cancelled = false) {\n const btn = document.querySelector('.submit-btn');\n btn.textContent = '\u63D0\u4EA4\u4E2D...';\n btn.style.opacity = '0.7';\n \n const options = window.predefinedOptions || [];\n const selectedTexts = Array.from(state.selectedOptions).map(i => options[i]);\n \n const data = cancelled ? { cancelled: true } : {\n cancelled: false,\n userInput: textarea.value.trim(),\n selectedOptions: selectedTexts,\n images: state.images.map(img => ({ data: img.data, mimeType: img.mimeType })),\n fileReferences: []\n };\n \n fetch('/submit', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data)\n }).then(() => {\n document.body.innerHTML = `\n <div class=\"success-page\">\n <div class=\"success-icon\">\u2705</div>\n <h2 class=\"success-title\">\u53CD\u9988\u5DF2\u63D0\u4EA4</h2>\n <p class=\"success-subtitle\">\u4F60\u53EF\u4EE5\u5173\u95ED\u6B64\u6807\u7B7E\u9875\u4E86</p>\n </div>\n `;\n setTimeout(() => window.close(), 1500);\n }).catch(() => {\n btn.textContent = '\u63D0\u4EA4';\n btn.style.opacity = '1';\n alert('\u63D0\u4EA4\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5');\n });\n}\n\n// \u952E\u76D8\u5FEB\u6377\u952E\ndocument.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n submit();\n }\n if (e.key === 'Escape') {\n submit(true);\n }\n});\n\ntextarea.focus();\n";
3
+ export declare function generateHtml(message: string, fullResponse: string | null, predefinedOptions: string[], theme?: string): string;
@@ -0,0 +1,627 @@
1
+ // CSS 样式 - 参考 rust-interactive-feedback 的设计风格
2
+ export const CSS = `
3
+ :root {
4
+ --bg-primary: #f8fafc;
5
+ --bg-secondary: #f1f5f9;
6
+ --bg-card: #ffffff;
7
+ --bg-glass: rgba(255, 255, 255, 0.8);
8
+ --bg-hover: #f1f5f9;
9
+ --text-primary: #1e293b;
10
+ --text-secondary: #64748b;
11
+ --text-muted: #94a3b8;
12
+ --border-color: #e2e8f0;
13
+ --border-subtle: #f1f5f9;
14
+ --accent-color: #3b82f6;
15
+ --accent-light: rgba(59, 130, 246, 0.1);
16
+ --accent-hover: #2563eb;
17
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
18
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
19
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
20
+ --blur-amount: 20px;
21
+ --scrollbar-thumb: #cbd5e1;
22
+ --scrollbar-thumb-hover: #94a3b8;
23
+ }
24
+
25
+ [data-theme="dark"] {
26
+ --bg-primary: #0f172a;
27
+ --bg-secondary: #1e293b;
28
+ --bg-card: #1e293b;
29
+ --bg-glass: rgba(30, 41, 59, 0.8);
30
+ --bg-hover: #334155;
31
+ --text-primary: #f1f5f9;
32
+ --text-secondary: #94a3b8;
33
+ --text-muted: #64748b;
34
+ --border-color: #334155;
35
+ --border-subtle: #1e293b;
36
+ --accent-color: #60a5fa;
37
+ --accent-light: rgba(96, 165, 250, 0.15);
38
+ --accent-hover: #3b82f6;
39
+ --scrollbar-thumb: #475569;
40
+ --scrollbar-thumb-hover: #64748b;
41
+ }
42
+
43
+ * {
44
+ box-sizing: border-box;
45
+ margin: 0;
46
+ padding: 0;
47
+ }
48
+
49
+ body {
50
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
51
+ background: var(--bg-primary);
52
+ color: var(--text-primary);
53
+ min-height: 100vh;
54
+ line-height: 1.6;
55
+ }
56
+
57
+ .app-container {
58
+ display: flex;
59
+ flex-direction: column;
60
+ height: 100vh;
61
+ overflow: hidden;
62
+ }
63
+
64
+ /* 主布局 */
65
+ .main-layout {
66
+ flex: 1;
67
+ display: flex;
68
+ flex-direction: column;
69
+ padding: 16px;
70
+ gap: 16px;
71
+ overflow: hidden;
72
+ }
73
+
74
+ /* 左侧面板 - 显示内容 + 选项 */
75
+ .left-panel {
76
+ flex: 1;
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 12px;
80
+ min-height: 0;
81
+ overflow: hidden;
82
+ }
83
+
84
+ /* 显示区域 */
85
+ .display-area {
86
+ flex: 1;
87
+ min-height: 200px;
88
+ background: var(--bg-card);
89
+ border-radius: 12px;
90
+ border: 1px solid var(--border-color);
91
+ overflow: hidden;
92
+ display: flex;
93
+ flex-direction: column;
94
+ }
95
+
96
+ .display-header {
97
+ padding: 12px 16px;
98
+ border-bottom: 1px solid var(--border-subtle);
99
+ font-size: 12px;
100
+ font-weight: 600;
101
+ color: var(--text-secondary);
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.05em;
104
+ background: var(--bg-secondary);
105
+ }
106
+
107
+ .display-content {
108
+ flex: 1;
109
+ padding: 16px;
110
+ overflow: auto;
111
+ font-size: 14px;
112
+ white-space: pre-wrap;
113
+ word-break: break-word;
114
+ }
115
+
116
+ .display-content::-webkit-scrollbar {
117
+ width: 6px;
118
+ }
119
+
120
+ .display-content::-webkit-scrollbar-thumb {
121
+ background: var(--scrollbar-thumb);
122
+ border-radius: 3px;
123
+ }
124
+
125
+ .display-content::-webkit-scrollbar-thumb:hover {
126
+ background: var(--scrollbar-thumb-hover);
127
+ }
128
+
129
+ /* 选项区域 */
130
+ .options-area {
131
+ flex-shrink: 0;
132
+ max-height: 200px;
133
+ overflow: auto;
134
+ }
135
+
136
+ .options-list {
137
+ display: flex;
138
+ flex-wrap: wrap;
139
+ gap: 8px;
140
+ }
141
+
142
+ .option-item {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ padding: 10px 14px;
147
+ background: var(--bg-card);
148
+ border: 1px solid var(--border-color);
149
+ border-radius: 8px;
150
+ cursor: pointer;
151
+ transition: all 0.2s ease;
152
+ user-select: none;
153
+ }
154
+
155
+ .option-item:hover {
156
+ background: var(--bg-hover);
157
+ border-color: var(--accent-color);
158
+ }
159
+
160
+ .option-item.selected {
161
+ background: var(--accent-light);
162
+ border-color: var(--accent-color);
163
+ }
164
+
165
+ .option-checkbox {
166
+ width: 18px;
167
+ height: 18px;
168
+ border: 2px solid var(--border-color);
169
+ border-radius: 4px;
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ transition: all 0.2s ease;
174
+ flex-shrink: 0;
175
+ }
176
+
177
+ .option-item.selected .option-checkbox {
178
+ background: var(--accent-color);
179
+ border-color: var(--accent-color);
180
+ }
181
+
182
+ .option-checkbox svg {
183
+ width: 12px;
184
+ height: 12px;
185
+ color: white;
186
+ opacity: 0;
187
+ }
188
+
189
+ .option-item.selected .option-checkbox svg {
190
+ opacity: 1;
191
+ }
192
+
193
+ .option-text {
194
+ font-size: 14px;
195
+ color: var(--text-primary);
196
+ }
197
+
198
+ /* 右侧面板 - 输入区域 */
199
+ .right-panel {
200
+ flex-shrink: 0;
201
+ height: 180px;
202
+ display: flex;
203
+ flex-direction: column;
204
+ }
205
+
206
+ .input-wrapper {
207
+ flex: 1;
208
+ display: flex;
209
+ flex-direction: column;
210
+ background: var(--bg-card);
211
+ border-radius: 12px;
212
+ border: 1px solid var(--border-color);
213
+ overflow: hidden;
214
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
215
+ }
216
+
217
+ .input-wrapper:focus-within {
218
+ border-color: var(--accent-color);
219
+ box-shadow: 0 0 0 3px var(--accent-light);
220
+ }
221
+
222
+ .feedback-input {
223
+ flex: 1;
224
+ width: 100%;
225
+ padding: 14px 16px;
226
+ border: none;
227
+ background: transparent;
228
+ color: var(--text-primary);
229
+ font-size: 14px;
230
+ line-height: 1.6;
231
+ resize: none;
232
+ outline: none;
233
+ font-family: inherit;
234
+ }
235
+
236
+ .feedback-input::placeholder {
237
+ color: var(--text-muted);
238
+ }
239
+
240
+ /* 图片预览区域 */
241
+ .images-preview {
242
+ display: flex;
243
+ flex-wrap: wrap;
244
+ gap: 8px;
245
+ padding: 8px 12px;
246
+ border-top: 1px solid var(--border-subtle);
247
+ background: var(--bg-secondary);
248
+ }
249
+
250
+ .images-preview:empty {
251
+ display: none;
252
+ }
253
+
254
+ .image-item {
255
+ position: relative;
256
+ width: 60px;
257
+ height: 60px;
258
+ border-radius: 8px;
259
+ overflow: hidden;
260
+ border: 1px solid var(--border-color);
261
+ }
262
+
263
+ .image-item img {
264
+ width: 100%;
265
+ height: 100%;
266
+ object-fit: cover;
267
+ }
268
+
269
+ .image-item .remove-btn {
270
+ position: absolute;
271
+ top: -6px;
272
+ right: -6px;
273
+ width: 20px;
274
+ height: 20px;
275
+ background: #ef4444;
276
+ color: white;
277
+ border: 2px solid white;
278
+ border-radius: 50%;
279
+ cursor: pointer;
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: center;
283
+ font-size: 14px;
284
+ line-height: 1;
285
+ opacity: 0;
286
+ transition: opacity 0.2s;
287
+ }
288
+
289
+ .image-item:hover .remove-btn {
290
+ opacity: 1;
291
+ }
292
+
293
+ /* 底部工具栏 */
294
+ .bottom-toolbar {
295
+ flex-shrink: 0;
296
+ display: flex;
297
+ justify-content: flex-end;
298
+ align-items: center;
299
+ gap: 12px;
300
+ padding: 12px 16px;
301
+ background: var(--bg-glass);
302
+ backdrop-filter: blur(var(--blur-amount));
303
+ border-top: 1px solid var(--border-color);
304
+ }
305
+
306
+ .helper-text {
307
+ flex: 1;
308
+ font-size: 12px;
309
+ color: var(--text-muted);
310
+ }
311
+
312
+ .kbd {
313
+ display: inline-block;
314
+ padding: 2px 6px;
315
+ background: var(--bg-secondary);
316
+ border: 1px solid var(--border-color);
317
+ border-radius: 4px;
318
+ font-family: monospace;
319
+ font-size: 11px;
320
+ }
321
+
322
+ .cancel-btn {
323
+ padding: 10px 20px;
324
+ background: transparent;
325
+ border: 1px solid var(--border-color);
326
+ border-radius: 8px;
327
+ color: var(--text-secondary);
328
+ font-size: 14px;
329
+ font-weight: 500;
330
+ cursor: pointer;
331
+ transition: all 0.2s ease;
332
+ }
333
+
334
+ .cancel-btn:hover {
335
+ background: var(--bg-hover);
336
+ color: var(--text-primary);
337
+ }
338
+
339
+ .submit-btn {
340
+ padding: 10px 24px;
341
+ background: var(--accent-color);
342
+ border: none;
343
+ border-radius: 8px;
344
+ color: white;
345
+ font-size: 14px;
346
+ font-weight: 500;
347
+ cursor: pointer;
348
+ transition: all 0.2s ease;
349
+ box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
350
+ }
351
+
352
+ .submit-btn:hover {
353
+ background: var(--accent-hover);
354
+ transform: translateY(-1px);
355
+ box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
356
+ }
357
+
358
+ .submit-btn:active {
359
+ transform: translateY(0);
360
+ }
361
+
362
+ /* 成功页面 */
363
+ .success-page {
364
+ display: flex;
365
+ flex-direction: column;
366
+ align-items: center;
367
+ justify-content: center;
368
+ height: 100vh;
369
+ gap: 16px;
370
+ animation: fadeIn 0.4s ease;
371
+ }
372
+
373
+ .success-icon {
374
+ font-size: 64px;
375
+ }
376
+
377
+ .success-title {
378
+ font-size: 24px;
379
+ font-weight: 600;
380
+ color: var(--text-primary);
381
+ }
382
+
383
+ .success-subtitle {
384
+ font-size: 14px;
385
+ color: var(--text-secondary);
386
+ }
387
+
388
+ @keyframes fadeIn {
389
+ from { opacity: 0; transform: translateY(10px); }
390
+ to { opacity: 1; transform: translateY(0); }
391
+ }
392
+
393
+ /* 响应式 */
394
+ @media (min-width: 768px) {
395
+ .main-layout {
396
+ flex-direction: row;
397
+ }
398
+
399
+ .left-panel {
400
+ flex: 1;
401
+ }
402
+
403
+ .right-panel {
404
+ width: 350px;
405
+ height: auto;
406
+ }
407
+ }
408
+ `;
409
+ // JavaScript 脚本
410
+ export const SCRIPT = `
411
+ const state = {
412
+ selectedOptions: new Set(),
413
+ images: [],
414
+ userInput: ''
415
+ };
416
+
417
+ const textarea = document.getElementById('feedbackInput');
418
+ const imagesPreview = document.getElementById('imagesPreview');
419
+
420
+ // 选项切换
421
+ function toggleOption(index) {
422
+ const item = document.querySelectorAll('.option-item')[index];
423
+ if (state.selectedOptions.has(index)) {
424
+ state.selectedOptions.delete(index);
425
+ item.classList.remove('selected');
426
+ } else {
427
+ state.selectedOptions.add(index);
428
+ item.classList.add('selected');
429
+ }
430
+ }
431
+
432
+ // 渲染图片预览
433
+ function renderImages() {
434
+ imagesPreview.innerHTML = state.images.map((img, i) =>
435
+ \`<div class="image-item">
436
+ <img src="\${img.dataUrl}" alt="preview">
437
+ <button class="remove-btn" onclick="removeImage(\${i})">×</button>
438
+ </div>\`
439
+ ).join('');
440
+ }
441
+
442
+ // 移除图片
443
+ function removeImage(index) {
444
+ state.images.splice(index, 1);
445
+ renderImages();
446
+ }
447
+
448
+ // 粘贴图片
449
+ document.addEventListener('paste', (e) => {
450
+ const items = e.clipboardData?.items;
451
+ if (!items) return;
452
+
453
+ for (const item of items) {
454
+ if (item.type.startsWith('image/')) {
455
+ e.preventDefault();
456
+ const file = item.getAsFile();
457
+ if (file) {
458
+ const reader = new FileReader();
459
+ reader.onload = (ev) => {
460
+ const dataUrl = ev.target?.result;
461
+ if (typeof dataUrl === 'string') {
462
+ const base64 = dataUrl.split(',')[1];
463
+ state.images.push({
464
+ dataUrl,
465
+ data: base64,
466
+ mimeType: item.type
467
+ });
468
+ renderImages();
469
+ }
470
+ };
471
+ reader.readAsDataURL(file);
472
+ }
473
+ break;
474
+ }
475
+ }
476
+ });
477
+
478
+ // 提交
479
+ function submit(cancelled = false) {
480
+ const btn = document.querySelector('.submit-btn');
481
+ btn.textContent = '提交中...';
482
+ btn.style.opacity = '0.7';
483
+
484
+ const options = window.predefinedOptions || [];
485
+ const selectedTexts = Array.from(state.selectedOptions).map(i => options[i]);
486
+
487
+ const data = cancelled ? { cancelled: true } : {
488
+ cancelled: false,
489
+ userInput: textarea.value.trim(),
490
+ selectedOptions: selectedTexts,
491
+ images: state.images.map(img => ({ data: img.data, mimeType: img.mimeType })),
492
+ fileReferences: []
493
+ };
494
+
495
+ fetch('/submit', {
496
+ method: 'POST',
497
+ headers: { 'Content-Type': 'application/json' },
498
+ body: JSON.stringify(data)
499
+ }).then(() => {
500
+ document.body.innerHTML = \`
501
+ <div class="success-page">
502
+ <div class="success-icon">✅</div>
503
+ <h2 class="success-title">反馈已提交</h2>
504
+ <p class="success-subtitle">你可以关闭此标签页了</p>
505
+ </div>
506
+ \`;
507
+ setTimeout(() => window.close(), 1500);
508
+ }).catch(() => {
509
+ btn.textContent = '提交';
510
+ btn.style.opacity = '1';
511
+ alert('提交失败,请重试');
512
+ });
513
+ }
514
+
515
+ // 键盘快捷键
516
+ document.addEventListener('keydown', (e) => {
517
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
518
+ submit();
519
+ }
520
+ if (e.key === 'Escape') {
521
+ submit(true);
522
+ }
523
+ });
524
+
525
+ textarea.focus();
526
+ `;
527
+ // 生成 HTML
528
+ export function generateHtml(message, fullResponse, predefinedOptions, theme = "auto") {
529
+ const escapeHtml = (str) => str
530
+ .replace(/&/g, "&amp;")
531
+ .replace(/</g, "&lt;")
532
+ .replace(/>/g, "&gt;")
533
+ .replace(/"/g, "&quot;")
534
+ .replace(/'/g, "&#039;");
535
+ const displayContent = fullResponse || message;
536
+ const escapedContent = escapeHtml(displayContent);
537
+ const optionsHtml = predefinedOptions
538
+ .map((opt, i) => `
539
+ <div class="option-item" onclick="toggleOption(${i})">
540
+ <span class="option-checkbox">
541
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
542
+ <polyline points="20 6 9 17 4 12"></polyline>
543
+ </svg>
544
+ </span>
545
+ <span class="option-text">${escapeHtml(opt)}</span>
546
+ </div>
547
+ `)
548
+ .join("");
549
+ return `<!DOCTYPE html>
550
+ <html lang="zh-CN">
551
+ <head>
552
+ <meta charset="UTF-8">
553
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
554
+ <title>AI Interactive Feedback</title>
555
+ <style>${CSS}</style>
556
+ </head>
557
+ <body>
558
+ <div class="app-container">
559
+ <div class="main-layout">
560
+ <!-- 左侧:内容 + 选项 -->
561
+ <div class="left-panel">
562
+ <div class="display-area">
563
+ <div class="display-header">AI 输出内容</div>
564
+ <div class="display-content">${escapedContent}</div>
565
+ </div>
566
+
567
+ ${predefinedOptions.length > 0
568
+ ? `
569
+ <div class="options-area">
570
+ <div class="options-list">
571
+ ${optionsHtml}
572
+ </div>
573
+ </div>
574
+ `
575
+ : ""}
576
+ </div>
577
+
578
+ <!-- 右侧:输入区域 -->
579
+ <div class="right-panel">
580
+ <div class="input-wrapper">
581
+ <textarea
582
+ id="feedbackInput"
583
+ class="feedback-input"
584
+ placeholder="输入您的反馈... (Enter 发送, Shift+Enter 换行)"
585
+ ></textarea>
586
+ <div id="imagesPreview" class="images-preview"></div>
587
+ </div>
588
+ </div>
589
+ </div>
590
+
591
+ <!-- 底部工具栏 -->
592
+ <div class="bottom-toolbar">
593
+ <div class="helper-text">
594
+ 粘贴图片 <span class="kbd" id="pasteKey">Ctrl+V</span> ·
595
+ 提交 <span class="kbd" id="submitKey">Ctrl+Enter</span> ·
596
+ 取消 <span class="kbd">Esc</span>
597
+ </div>
598
+ <button class="cancel-btn" onclick="submit(true)">取消</button>
599
+ <button class="submit-btn" onclick="submit()">提交</button>
600
+ </div>
601
+ </div>
602
+
603
+ <script>
604
+ // 初始化主题
605
+ const configTheme = '${theme}';
606
+ if (configTheme === 'dark') {
607
+ document.documentElement.setAttribute('data-theme', 'dark');
608
+ } else if (configTheme === 'light') {
609
+ document.documentElement.setAttribute('data-theme', 'light');
610
+ } else {
611
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
612
+ document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
613
+ }
614
+
615
+ // 检测 Mac 并更新快捷键显示
616
+ if (navigator.platform.indexOf('Mac') !== -1) {
617
+ document.getElementById('pasteKey').textContent = '⌘V';
618
+ document.getElementById('submitKey').textContent = '⌘Enter';
619
+ }
620
+
621
+ // 传递预定义选项
622
+ window.predefinedOptions = ${JSON.stringify(predefinedOptions)};
623
+ </script>
624
+ <script>${SCRIPT}</script>
625
+ </body>
626
+ </html>`;
627
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "ai-interactive-feedback",
3
+ "version": "1.0.0",
4
+ "description": "AI 交互式反馈 MCP 工具,通过浏览器 GUI 收集用户反馈",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "ai-interactive-feedback": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "start": "node dist/index.js",
16
+ "dev": "tsx src/index.ts",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "ai",
22
+ "feedback",
23
+ "interactive",
24
+ "windsurf",
25
+ "cursor",
26
+ "claude",
27
+ "model-context-protocol"
28
+ ],
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.0.0",
38
+ "tsx": "^4.0.0",
39
+ "typescript": "^5.0.0"
40
+ }
41
+ }