Răsfoiți Sursa

feat: plotting system + MCP-console sync + save to gallery (v1.2)

Plotting System:
- SymjaEngine: parseGraphicsToJson() extracts Line/Point from Graphics output
- Canvas renderer (PlotRenderer) with axes, grid, labels, multi-color curves
- Touch gestures: pinch-zoom, drag-pan, double-tap reset
- MCP tools: plot, parametric_plot, list_plot (15 tools total)
- Auto-detect Graphics in evaluate() for WebView Canvas rendering
- Plugin API: Symja.plot(), Symja.parametricPlot(), Symja.listPlot()
- Handles Symja's *10^ scientific notation

MCP-Console Sync:
- AppSession event queue for real-time MCP evaluation display
- 🌐 MCP badge distinguishes remote vs local inputs
- Plot JSON results automatically render as Canvas in console

Image Save:
- MediaStore API saves plot Canvas to Pictures/IJS/ (visible in Gallery)
- 💾 save button on each plot container

Version: 1.1 → 1.2 (versionCode 2 → 3)

Co-Authored-By: Claude <noreply@anthropic.com>
nonvioce 3 luni în urmă
părinte
comite
e857f69f3d

+ 2 - 2
app/build.gradle.kts

@@ -13,8 +13,8 @@ android {
         applicationId = "com.nonvioce.ijs"
         applicationId = "com.nonvioce.ijs"
         minSdk = 24
         minSdk = 24
         targetSdk = 34
         targetSdk = 34
-        versionCode = 2
-        versionName = "1.1"
+        versionCode = 3
+        versionName = "1.2"
 
 
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
         
         

+ 91 - 0
app/src/main/assets/console.css

@@ -1481,3 +1481,94 @@ body {
 .feedback-copy-btn:active {
 .feedback-copy-btn:active {
     transform: translateY(0);
     transform: translateY(0);
 }
 }
+
+/* ── 绘图 ─────────────────────────────────────────── */
+.plot-container {
+    margin: 12px 0;
+    padding: 8px;
+    background: #FFFFFF;
+    border: 1px solid var(--terminal-border);
+    border-radius: 8px;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+    overflow: hidden;
+}
+.plot-canvas {
+    display: block;
+    width: 100%;
+    height: auto;
+    border-radius: 4px;
+    touch-action: none;
+}
+
+/* ── MCP 同步事件 ────────────────────────────────── */
+.mcp-input-line {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 2px;
+}
+.mcp-badge {
+    font-size: 10px;
+    font-weight: 600;
+    background: #E3F2FD;
+    color: #1976D2;
+    padding: 2px 6px;
+    border-radius: 3px;
+    flex-shrink: 0;
+}
+.mcp-prompt {
+    font-family: var(--font-mono);
+    font-size: 13px;
+    color: #90CAF9;
+    flex-shrink: 0;
+    min-width: 40px;
+}
+.mcp-command-text {
+    font-family: var(--font-mono);
+    font-size: 13px;
+    color: #1565C0;
+}
+.mcp-result-line {
+    display: flex;
+    gap: 8px;
+    padding: 8px 12px;
+    margin: 2px 0 10px 0;
+    background: #F5F8FC;
+    border-left: 3px solid #90CAF9;
+    border-radius: 4px;
+    font-family: var(--font-mono);
+    font-size: 13px;
+    color: var(--terminal-fg);
+    word-break: break-word;
+}
+.mcp-result-text {
+    flex: 1;
+}
+
+/* ── 绘图操作按钮 ────────────────────────────────── */
+.plot-actions {
+    display: flex;
+    gap: 8px;
+    padding: 6px 0 0 0;
+    border-top: 1px solid #E0E0E0;
+    margin-top: 4px;
+}
+.plot-save-btn {
+    padding: 6px 14px;
+    border: 1px solid var(--terminal-border);
+    border-radius: 6px;
+    background: #FFFFFF;
+    color: var(--primary-color);
+    font-size: 12px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+.plot-save-btn:hover {
+    background: var(--primary-light);
+    border-color: var(--primary-color);
+}
+.plot-save-btn:active {
+    background: var(--primary-color);
+    color: #FFFFFF;
+}

+ 405 - 9
app/src/main/assets/console.js

@@ -53,6 +53,10 @@ class SymjaConsole {
         // MCP 状态更新
         // MCP 状态更新
         this.updateMcpStatus();
         this.updateMcpStatus();
         setInterval(() => this.updateMcpStatus(), 3000);
         setInterval(() => this.updateMcpStatus(), 3000);
+
+        // MCP 事件同步(每秒轮询)
+        this.mcpEventCount = 0;
+        setInterval(() => this.pollMcpEvents(), 1000);
         
         
         // 快捷键工具栏
         // 快捷键工具栏
         this.initShortcutsBar();
         this.initShortcutsBar();
@@ -421,14 +425,13 @@ class SymjaConsole {
 
 
     evaluate() {
     evaluate() {
         let expression = this.input.value.trim();
         let expression = this.input.value.trim();
-        
+
         if (!expression) {
         if (!expression) {
             return;
             return;
         }
         }
 
 
         // 处理特殊命令
         // 处理特殊命令
         if (this.handleSpecialCommand(expression)) {
         if (this.handleSpecialCommand(expression)) {
-            // 特殊命令也要添加到历史记录
             this.addToHistory(expression);
             this.addToHistory(expression);
             this.input.value = '';
             this.input.value = '';
             return;
             return;
@@ -442,17 +445,23 @@ class SymjaConsole {
 
 
         // 调用Android接口进行计算
         // 调用Android接口进行计算
         try {
         try {
-            const result = Android.evaluate(expression);
-            
-            // 检查是否是特殊功能请求
-            if (result.startsWith('##TEXFORM##')) {
+            const result = Android.evaluatePlot(expression);
+
+            // 检测是否是 Graphics/绘图结果
+            if (typeof result === 'string' && result.startsWith('{"type":"plot"')) {
+                this.addPlotOutput(result, this.executionCount);
+            }
+            // LaTeX 渲染
+            else if (result.startsWith('##TEXFORM##')) {
                 const latex = result.substring(11);
                 const latex = result.substring(11);
                 this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
                 this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
                 this.scrollToBottom();
                 this.scrollToBottom();
-            } else {
+            }
+            // 普通文本结果
+            else {
                 this.addOutput(result, 'result', { count: this.executionCount });
                 this.addOutput(result, 'result', { count: this.executionCount });
             }
             }
-            
+
             // 追踪变量
             // 追踪变量
             this.trackVariables(expression);
             this.trackVariables(expression);
         } catch (e) {
         } catch (e) {
@@ -468,11 +477,59 @@ class SymjaConsole {
         this.input.value = '';
         this.input.value = '';
         this.completions = [];
         this.completions = [];
         this.completionIndex = -1;
         this.completionIndex = -1;
-        
+
         // 滚动到底部
         // 滚动到底部
         this.scrollToBottom();
         this.scrollToBottom();
     }
     }
 
 
+    addPlotOutput(plotJson, count) {
+        const data = JSON.parse(plotJson);
+        const line = document.createElement('div');
+        line.className = 'terminal-line';
+
+        const container = document.createElement('div');
+        container.className = 'plot-container';
+
+        const canvas = document.createElement('canvas');
+        canvas.className = 'plot-canvas';
+        canvas.width = 720;
+        canvas.height = 500;
+        container.appendChild(canvas);
+
+        // 保存按钮
+        const actions = document.createElement('div');
+        actions.className = 'plot-actions';
+        const saveBtn = document.createElement('button');
+        saveBtn.className = 'plot-save-btn';
+        saveBtn.textContent = '💾 保存图片';
+        saveBtn.onclick = () => this.savePlot(canvas);
+        actions.appendChild(saveBtn);
+        container.appendChild(actions);
+
+        line.appendChild(container);
+        this.output.appendChild(line);
+
+        // 初始化渲染器
+        if (!this.plotRenderer) {
+            this.plotRenderer = new PlotRenderer();
+        }
+        this.plotRenderer.render(canvas, data);
+    }
+
+    savePlot(canvas) {
+        try {
+            const dataUrl = canvas.toDataURL('image/png');
+            const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
+            const filename = 'IJS_Plot_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
+            const result = Android.savePlotImage(base64, filename);
+            this.addOutput(result, 'help');
+            this.scrollToBottom();
+        } catch (e) {
+            this.addOutput('保存失败: ' + e.message, 'error');
+            this.scrollToBottom();
+        }
+    }
+
     handleSpecialCommand(cmd) {
     handleSpecialCommand(cmd) {
         const lower = cmd.toLowerCase();
         const lower = cmd.toLowerCase();
         
         
@@ -917,6 +974,26 @@ class SymjaConsole {
                 }
                 }
             },
             },
             
             
+            // 绘图
+            plot: function(func, variable, xMin, xMax) {
+                try {
+                    const r = Android.evaluatePlot('Plot[' + func + ', {' + variable + ', ' + xMin + ', ' + xMax + '}]');
+                    return r;
+                } catch (e) { return 'Error: ' + e.message; }
+            },
+            parametricPlot: function(xf, yf, t, tMin, tMax) {
+                try {
+                    const r = Android.evaluatePlot('ParametricPlot[{' + xf + ', ' + yf + '}, {' + t + ', ' + tMin + ', ' + tMax + '}]');
+                    return r;
+                } catch (e) { return 'Error: ' + e.message; }
+            },
+            listPlot: function(points) {
+                try {
+                    const r = Android.evaluatePlot('ListPlot[' + points + ']');
+                    return r;
+                } catch (e) { return 'Error: ' + e.message; }
+            },
+
             // LaTeX 转换和渲染
             // LaTeX 转换和渲染
             toTeX: function(expr) {
             toTeX: function(expr) {
                 try {
                 try {
@@ -1123,6 +1200,18 @@ class SymjaConsole {
         } else if (type === 'plugin') {
         } else if (type === 'plugin') {
             // 插件的返回值(可运算结果)
             // 插件的返回值(可运算结果)
             line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
             line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
+        } else if (type === 'mcp-input') {
+            const seq = options.seq || '';
+            line.innerHTML = `<div class="mcp-input-line">
+                <span class="mcp-badge">🌐 MCP</span>
+                <span class="mcp-prompt">[${seq}]:</span>
+                <span class="mcp-command-text">${this.escapeHtml(text)}</span>
+            </div>`;
+        } else if (type === 'mcp-result') {
+            line.innerHTML = `<div class="mcp-result-line">
+                <span class="mcp-prompt"></span>
+                <span class="mcp-result-text">${this.escapeHtml(text)}</span>
+            </div>`;
         }
         }
         
         
         this.output.appendChild(line);
         this.output.appendChild(line);
@@ -1780,6 +1869,31 @@ class SymjaConsole {
             status.className = 'mcp-status';
             status.className = 'mcp-status';
         }
         }
     }
     }
+
+    pollMcpEvents() {
+        try {
+            if (typeof Android === 'undefined' || !Android.getMcpEvents) return;
+            const json = Android.getMcpEvents();
+            if (!json || json === '[]') return;
+            const events = JSON.parse(json);
+            events.forEach(ev => {
+                this.mcpEventCount++;
+                this.addOutput(ev.expression, 'mcp-input', { seq: this.mcpEventCount });
+                // 检测绘图结果 → Canvas 渲染
+                if (ev.result && ev.result.startsWith('{"type":"plot"')) {
+                    this.addPlotOutput(ev.result, this.mcpEventCount);
+                } else {
+                    this.addOutput(ev.result, 'mcp-result', { seq: this.mcpEventCount });
+                }
+            });
+            this.executionCount++;
+            this.saveExecutionCount();
+            this.updatePrompt();
+            this.scrollToBottom();
+        } catch (e) {
+            // Ignore polling errors
+        }
+    }
 }
 }
 
 
 // 初始化控制台
 // 初始化控制台
@@ -1787,3 +1901,285 @@ let symjaConsole;
 document.addEventListener('DOMContentLoaded', () => {
 document.addEventListener('DOMContentLoaded', () => {
     symjaConsole = new SymjaConsole();
     symjaConsole = new SymjaConsole();
 });
 });
+
+// ── Canvas 绘图渲染器 ────────────────────────────────────────
+class PlotRenderer {
+    constructor() {
+        this.canvas = null;
+        this.ctx = null;
+        this.data = null;
+        this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
+        this.bounds = { xMin: 0, xMax: 1, yMin: 0, yMax: 1 };
+        this.dpr = window.devicePixelRatio || 1;
+        this.colors = [
+            [0.369, 0.507, 0.710], [0.851, 0.208, 0.125],
+            [0.267, 0.612, 0.329], [0.737, 0.349, 0.800],
+            [1.0, 0.608, 0.0]
+        ];
+    }
+
+    render(canvas, data) {
+        this.canvas = canvas;
+        this.ctx = canvas.getContext('2d');
+        this.data = data;
+
+        // 计算全局范围
+        this.bounds = this.computeBounds(data);
+        const padX = (this.bounds.xMax - this.bounds.xMin) * 0.05 || 0.5;
+        const padY = (this.bounds.yMax - this.bounds.yMin) * 0.05 || 0.5;
+        this.bounds.xMin -= padX;
+        this.bounds.xMax += padX;
+        this.bounds.yMin -= padY;
+        this.bounds.yMax += padY;
+
+        this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
+        this.draw();
+
+        // 手势绑定
+        this.bindGestures();
+    }
+
+    computeBounds(data) {
+        let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
+        if (!data.elements) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
+        for (const el of data.elements) {
+            if (!el.points) continue;
+            for (const pt of el.points) {
+                xMin = Math.min(xMin, pt[0]);
+                xMax = Math.max(xMax, pt[0]);
+                yMin = Math.min(yMin, pt[1]);
+                yMax = Math.max(yMax, pt[1]);
+            }
+        }
+        if (!isFinite(xMin)) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
+        return { xMin, xMax, yMin, yMax };
+    }
+
+    // ── 坐标变换 ──────────────────────────────────────
+    toCanvas(px, py) {
+        const w = this.canvas.width, h = this.canvas.height;
+        const m = 60; // 边距
+        const cw = w - 2 * m, ch = h - 2 * m;
+        const t = this.transform;
+        const b = this.bounds;
+        const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
+        return {
+            x: m + (px - b.xMin - t.ox * xRange) / (xRange * t.sx) * cw,
+            y: h - m - (py - b.yMin - t.oy * yRange) / (yRange * t.sy) * ch
+        };
+    }
+
+    // ── 主绘制 ────────────────────────────────────────
+    draw() {
+        const ctx = this.ctx;
+        const w = this.canvas.width, h = this.canvas.height;
+        const m = 60;
+        const cw = w - 2 * m, ch = h - 2 * m;
+        const t = this.transform;
+        const b = this.bounds;
+
+        // 背景
+        ctx.fillStyle = '#FFFFFF';
+        ctx.fillRect(0, 0, w, h);
+
+        // 网格
+        this.drawGrid(m, cw, ch);
+
+        // 坐标轴
+        this.drawAxes(m, cw, ch);
+
+        // 数据曲线
+        if (this.data.elements) {
+            const useColors = this.colors;
+            this.data.elements.forEach((el, idx) => {
+                if (!el.points || el.points.length === 0) return;
+                const color = el.color || useColors[idx % useColors.length];
+                const rgb = `rgb(${Math.round(color[0]*255)},${Math.round(color[1]*255)},${Math.round(color[2]*255)})`;
+
+                if (el.type === 'line' || (el.type === 'point' && this.data.options && this.data.options.joined)) {
+                    ctx.strokeStyle = rgb;
+                    ctx.lineWidth = 2;
+                    ctx.beginPath();
+                    for (let i = 0; i < el.points.length; i++) {
+                        const pt = this.toCanvas(el.points[i][0], el.points[i][1]);
+                        if (i === 0) ctx.moveTo(pt.x, pt.y);
+                        else ctx.lineTo(pt.x, pt.y);
+                    }
+                    ctx.stroke();
+                }
+
+                if (el.type === 'point') {
+                    ctx.fillStyle = rgb;
+                    for (const pt of el.points) {
+                        const p = this.toCanvas(pt[0], pt[1]);
+                        ctx.beginPath();
+                        ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
+                        ctx.fill();
+                    }
+                }
+            });
+        }
+    }
+
+    drawGrid(m, cw, ch) {
+        const ctx = this.ctx;
+        const b = this.bounds;
+        const t = this.transform;
+        const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
+
+        ctx.strokeStyle = '#E8EDF2';
+        ctx.lineWidth = 0.5;
+        ctx.fillStyle = '#8B95A5';
+        ctx.font = '11px -apple-system, sans-serif';
+        ctx.textAlign = 'center';
+
+        const xStep = this.niceStep(xRange, 5);
+        const yStep = this.niceStep(yRange, 5);
+
+        // X 网格 + 标签
+        let xStart = Math.floor((b.xMin + t.ox * xRange) / xStep) * xStep;
+        for (let x = xStart; x <= b.xMin + t.ox * xRange + xRange / t.sx + xStep; x += xStep) {
+            const pt = this.toCanvas(x, 0);
+            if (pt.x >= m && pt.x <= m + cw) {
+                ctx.beginPath();
+                ctx.moveTo(pt.x, m);
+                ctx.lineTo(pt.x, m + ch);
+                ctx.stroke();
+                ctx.fillText(this.fmtNum(x), pt.x, m + ch + 16);
+            }
+        }
+
+        // Y 网格 + 标签
+        ctx.textAlign = 'right';
+        let yStart = Math.floor((b.yMin + t.oy * yRange) / yStep) * yStep;
+        for (let y = yStart; y <= b.yMin + t.oy * yRange + yRange / t.sy + yStep; y += yStep) {
+            const pt = this.toCanvas(0, y);
+            if (pt.y >= m && pt.y <= m + ch) {
+                ctx.beginPath();
+                ctx.moveTo(m, pt.y);
+                ctx.lineTo(m + cw, pt.y);
+                ctx.stroke();
+                ctx.fillText(this.fmtNum(y), m - 8, pt.y + 4);
+            }
+        }
+    }
+
+    drawAxes(m, cw, ch) {
+        const ctx = this.ctx;
+        const b = this.bounds;
+        const t = this.transform;
+        const origin = this.toCanvas(0, 0);
+
+        ctx.strokeStyle = '#1976D2';
+        ctx.lineWidth = 1.2;
+        ctx.fillStyle = '#1976D2';
+        ctx.font = 'bold 11px -apple-system, sans-serif';
+
+        // X 轴
+        if (origin.y >= m && origin.y <= m + ch) {
+            ctx.beginPath();
+            ctx.moveTo(m, origin.y);
+            ctx.lineTo(m + cw, origin.y);
+            ctx.stroke();
+            // 箭头
+            ctx.beginPath();
+            ctx.moveTo(m + cw, origin.y);
+            ctx.lineTo(m + cw - 8, origin.y - 4);
+            ctx.lineTo(m + cw - 8, origin.y + 4);
+            ctx.fill();
+        }
+
+        // Y 轴
+        if (origin.x >= m && origin.x <= m + cw) {
+            ctx.beginPath();
+            ctx.moveTo(origin.x, m + ch);
+            ctx.lineTo(origin.x, m);
+            ctx.stroke();
+            // 箭头
+            ctx.beginPath();
+            ctx.moveTo(origin.x, m);
+            ctx.lineTo(origin.x - 4, m + 8);
+            ctx.lineTo(origin.x + 4, m + 8);
+            ctx.fill();
+        }
+
+        // 原点标签
+        if (origin.x >= m && origin.x <= m + cw && origin.y >= m && origin.y <= m + ch) {
+            ctx.textAlign = 'right';
+            ctx.fillText('O', origin.x - 6, origin.y + 14);
+        }
+    }
+
+    niceStep(range, target) {
+        const rough = range / target;
+        const exp = Math.pow(10, Math.floor(Math.log10(rough)));
+        const mant = rough / exp;
+        let nice = 1;
+        if (mant <= 1.5) nice = 1;
+        else if (mant <= 3.5) nice = 2;
+        else if (mant <= 7.5) nice = 5;
+        else nice = 10;
+        return nice * exp;
+    }
+
+    fmtNum(n) {
+        if (Math.abs(n) < 1e-10) return '0';
+        if (Math.abs(n) < 0.001 || Math.abs(n) >= 1e6) return n.toExponential(2);
+        if (Number.isInteger(n)) return n.toString();
+        const s = n.toFixed(4);
+        return s.replace(/0+$/, '').replace(/\.$/, '');
+    }
+
+    // ── 手势 ──────────────────────────────────────────
+    bindGestures() {
+        const canvas = this.canvas;
+        let lastDist = 0, lastX = 0, lastY = 0;
+        let lastTap = 0;
+
+        canvas.addEventListener('touchstart', (e) => {
+            if (e.touches.length === 2) {
+                lastDist = Math.hypot(
+                    e.touches[0].clientX - e.touches[1].clientX,
+                    e.touches[0].clientY - e.touches[1].clientY
+                );
+            } else if (e.touches.length === 1) {
+                lastX = e.touches[0].clientX;
+                lastY = e.touches[0].clientY;
+            }
+            e.preventDefault();
+        }, { passive: false });
+
+        canvas.addEventListener('touchmove', (e) => {
+            if (e.touches.length === 2) {
+                const dist = Math.hypot(
+                    e.touches[0].clientX - e.touches[1].clientX,
+                    e.touches[0].clientY - e.touches[1].clientY
+                );
+                const scale = dist / lastDist;
+                this.transform.sx = Math.max(0.1, Math.min(20, this.transform.sx / scale));
+                this.transform.sy = Math.max(0.1, Math.min(20, this.transform.sy / scale));
+                lastDist = dist;
+                this.draw();
+            } else if (e.touches.length === 1) {
+                const dx = (e.touches[0].clientX - lastX) / canvas.clientWidth;
+                const dy = (e.touches[0].clientY - lastY) / canvas.clientHeight;
+                this.transform.ox -= dx / this.transform.sx;
+                this.transform.oy += dy / this.transform.sy;
+                lastX = e.touches[0].clientX;
+                lastY = e.touches[0].clientY;
+                this.draw();
+            }
+            e.preventDefault();
+        }, { passive: false });
+
+        canvas.addEventListener('touchend', (e) => {
+            const now = Date.now();
+            if (now - lastTap < 300) {
+                // 双击重置
+                this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
+                this.draw();
+            }
+            lastTap = now;
+        });
+    }
+}

+ 39 - 1
app/src/main/java/com/nonvioce/ijs/AppSession.java

@@ -1,14 +1,22 @@
 package com.nonvioce.ijs;
 package com.nonvioce.ijs;
 
 
+import org.json.JSONArray;
+import org.json.JSONObject;
+import java.util.ArrayList;
+import java.util.List;
+
 /**
 /**
  * Singleton holder for the shared Symja computation session.
  * Singleton holder for the shared Symja computation session.
  * Ensures the WebView console and MCP server share the same
  * Ensures the WebView console and MCP server share the same
- * variables, last-output (% reference), and plugin state.
+ * variables, last-output (% reference), plugin state, and
+ * MCP evaluation events (for real-time console sync).
  */
  */
 public class AppSession {
 public class AppSession {
 
 
     private static final SymjaEngine symjaEngine = new SymjaEngine();
     private static final SymjaEngine symjaEngine = new SymjaEngine();
     private static PluginManager pluginManager;
     private static PluginManager pluginManager;
+    private static final List<String> mcpEventQueue = new ArrayList<>();
+    private static int mcpEventSeq = 0;
 
 
     private AppSession() {}
     private AppSession() {}
 
 
@@ -22,4 +30,34 @@ public class AppSession {
         }
         }
         return pluginManager;
         return pluginManager;
     }
     }
+
+    /** MCP 执行了一个表达式,记录事件供 WebView 同步显示 */
+    public static synchronized void publishMcpEvent(String expression, String result) {
+        try {
+            JSONObject event = new JSONObject();
+            event.put("seq", ++mcpEventSeq);
+            event.put("expression", expression);
+            // plot JSON 可能超过 10000 字符,不截断
+            event.put("result", result);
+            mcpEventQueue.add(event.toString());
+            // 只保留最近 50 条
+            while (mcpEventQueue.size() > 50) {
+                mcpEventQueue.remove(0);
+            }
+        } catch (Exception ignored) {}
+    }
+
+    /** WebView 轮询获取新事件(返回 JSON 数组,消费后清空) */
+    public static synchronized String drainMcpEvents() {
+        try {
+            JSONArray arr = new JSONArray();
+            for (String e : mcpEventQueue) {
+                arr.put(new JSONObject(e));
+            }
+            mcpEventQueue.clear();
+            return arr.toString();
+        } catch (Exception e) {
+            return "[]";
+        }
+    }
 }
 }

+ 33 - 0
app/src/main/java/com/nonvioce/ijs/MainActivity.java

@@ -412,6 +412,39 @@ public class MainActivity extends AppCompatActivity {
             return symjaEngine.binomial(n, k);
             return symjaEngine.binomial(n, k);
         }
         }
 
 
+        @JavascriptInterface
+        public String evaluatePlot(String expression) {
+            return symjaEngine.evaluatePlot(expression);
+        }
+
+        @JavascriptInterface
+        public String savePlotImage(String base64, String filename) {
+            try {
+                byte[] data = android.util.Base64.decode(base64, android.util.Base64.DEFAULT);
+                android.content.ContentValues values = new android.content.ContentValues();
+                values.put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename);
+                values.put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png");
+                values.put(android.provider.MediaStore.Images.Media.RELATIVE_PATH,
+                    android.os.Environment.DIRECTORY_PICTURES + "/IJS");
+                android.net.Uri uri = getContentResolver().insert(
+                    android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
+                if (uri != null) {
+                    java.io.OutputStream os = getContentResolver().openOutputStream(uri);
+                    os.write(data);
+                    os.close();
+                    return "✅ 已保存到相册 Pictures/IJS/" + filename;
+                }
+                return "❌ 保存失败: 无法创建文件";
+            } catch (Exception e) {
+                return "❌ 保存失败: " + e.getMessage();
+            }
+        }
+
+        @JavascriptInterface
+        public String getMcpEvents() {
+            return AppSession.drainMcpEvents();
+        }
+
         @JavascriptInterface
         @JavascriptInterface
         public boolean isMcpRunning() {
         public boolean isMcpRunning() {
             return McpService.isRunning();
             return McpService.isRunning();

+ 93 - 7
app/src/main/java/com/nonvioce/ijs/McpServer.java

@@ -277,6 +277,37 @@ public class McpServer extends NanoHTTPD {
                         stringProp("JSON object of input arguments, e.g. {\"radius\":\"5\"}")),
                         stringProp("JSON object of input arguments, e.g. {\"radius\":\"5\"}")),
                 "plugin_id")));
                 "plugin_id")));
 
 
+        // --- Plotting ---
+        tools.put(tool("plot",
+            "Plot a function f[x] over a range. Returns structured plot data " +
+            "with line point sequences as JSON. Use Symja math syntax.",
+            inputSchema(
+                props()
+                    .put("function", stringProp("Function expression, e.g. Sin[x] or x^2"))
+                    .put("variable", stringProp("Variable name (usually x)"))
+                    .put("x_min", stringProp("Lower bound"))
+                    .put("x_max", stringProp("Upper bound")),
+                "function", "variable", "x_min", "x_max")));
+
+        tools.put(tool("parametric_plot",
+            "Plot a parametric curve (x(t), y(t)). Returns structured plot data as JSON.",
+            inputSchema(
+                props()
+                    .put("x_func", stringProp("x(t) expression, e.g. Sin[t]"))
+                    .put("y_func", stringProp("y(t) expression, e.g. Cos[t]"))
+                    .put("t_var", stringProp("Parameter variable (usually t)"))
+                    .put("t_min", stringProp("Lower bound for t"))
+                    .put("t_max", stringProp("Upper bound for t")),
+                "x_func", "y_func", "t_var", "t_min", "t_max")));
+
+        tools.put(tool("list_plot",
+            "Plot a list of points as a scatter plot. Returns structured plot data as JSON.",
+            inputSchema(
+                props()
+                    .put("points", stringProp(
+                        "Point list in Symja format: {{x1,y1},{x2,y2},...}")),
+                "points")));
+
         JSONObject result = new JSONObject();
         JSONObject result = new JSONObject();
         result.put("tools", tools);
         result.put("tools", tools);
         return result;
         return result;
@@ -339,6 +370,24 @@ public class McpServer extends NanoHTTPD {
                     arguments.getString("plugin_id"),
                     arguments.getString("plugin_id"),
                     arguments.optJSONObject("arguments"));
                     arguments.optJSONObject("arguments"));
                 break;
                 break;
+            case "plot":
+                textResult = callPlot(
+                    arguments.getString("function"),
+                    arguments.getString("variable"),
+                    arguments.getString("x_min"),
+                    arguments.getString("x_max"));
+                break;
+            case "parametric_plot":
+                textResult = callParametricPlot(
+                    arguments.getString("x_func"),
+                    arguments.getString("y_func"),
+                    arguments.getString("t_var"),
+                    arguments.getString("t_min"),
+                    arguments.getString("t_max"));
+                break;
+            case "list_plot":
+                textResult = callListPlot(arguments.getString("points"));
+                break;
             default:
             default:
                 throw new JSONException("Unknown tool: " + name);
                 throw new JSONException("Unknown tool: " + name);
         }
         }
@@ -360,14 +409,18 @@ public class McpServer extends NanoHTTPD {
     // ── Tool implementations ──────────────────────────────────────
     // ── Tool implementations ──────────────────────────────────────
 
 
     private String callEvaluate(String expression) {
     private String callEvaluate(String expression) {
-        String result = symjaEngine.evaluate(expression);
+        // 使用 evaluatePlot 以自动解析 Graphics 为结构化 JSON
+        String result = symjaEngine.evaluatePlot(expression);
         if (evalHistory.size() >= 100) evalHistory.remove(0);
         if (evalHistory.size() >= 100) evalHistory.remove(0);
         evalHistory.add(expression + " → " + result);
         evalHistory.add(expression + " → " + result);
+        AppSession.publishMcpEvent(expression, result);
         return result;
         return result;
     }
     }
 
 
     private String callDifferentiate(String expression, String variable) {
     private String callDifferentiate(String expression, String variable) {
-        return symjaEngine.evaluate("D[" + expression + ", " + variable + "]");
+        String result = symjaEngine.evaluate("D[" + expression + ", " + variable + "]");
+        AppSession.publishMcpEvent("D[" + expression + ", " + variable + "]", result);
+        return result;
     }
     }
 
 
     private String callIntegrate(String expression, String variable, String lower, String upper) {
     private String callIntegrate(String expression, String variable, String lower, String upper) {
@@ -377,23 +430,33 @@ public class McpServer extends NanoHTTPD {
         } else {
         } else {
             expr = "Integrate[" + expression + ", " + variable + "]";
             expr = "Integrate[" + expression + ", " + variable + "]";
         }
         }
-        return symjaEngine.evaluate(expr);
+        String result = symjaEngine.evaluate(expr);
+        AppSession.publishMcpEvent(expr, result);
+        return result;
     }
     }
 
 
     private String callSolve(String equation, String variable) {
     private String callSolve(String equation, String variable) {
-        return symjaEngine.evaluate("Solve[" + equation + ", " + variable + "]");
+        String result = symjaEngine.evaluate("Solve[" + equation + ", " + variable + "]");
+        AppSession.publishMcpEvent("Solve[" + equation + ", " + variable + "]", result);
+        return result;
     }
     }
 
 
     private String callSimplify(String expression) {
     private String callSimplify(String expression) {
-        return symjaEngine.evaluate("Simplify[" + expression + "]");
+        String result = symjaEngine.evaluate("Simplify[" + expression + "]");
+        AppSession.publishMcpEvent("Simplify[" + expression + "]", result);
+        return result;
     }
     }
 
 
     private String callNumeric(String expression, int precision) {
     private String callNumeric(String expression, int precision) {
-        return symjaEngine.numericEval(expression, precision);
+        String result = symjaEngine.numericEval(expression, precision);
+        AppSession.publishMcpEvent("N[" + expression + ", " + precision + "]", result);
+        return result;
     }
     }
 
 
     private String callToLatex(String expression) {
     private String callToLatex(String expression) {
-        return symjaEngine.toTeX(expression);
+        String result = symjaEngine.toTeX(expression);
+        AppSession.publishMcpEvent("TeXForm[" + expression + "]", result);
+        return result;
     }
     }
 
 
     private String callListPlugins() {
     private String callListPlugins() {
@@ -457,6 +520,18 @@ public class McpServer extends NanoHTTPD {
         return pluginManager.deletePlugin(pluginId);
         return pluginManager.deletePlugin(pluginId);
     }
     }
 
 
+    private String callPlot(String function, String variable, String xMin, String xMax) {
+        return symjaEngine.plot(function, variable, xMin, xMax);
+    }
+
+    private String callParametricPlot(String xFunc, String yFunc, String tVar, String tMin, String tMax) {
+        return symjaEngine.parametricPlot(xFunc, yFunc, tVar, tMin, tMax);
+    }
+
+    private String callListPlot(String points) {
+        return symjaEngine.listPlot(points);
+    }
+
     private String callExecutePlugin(String pluginId, JSONObject argsObj) {
     private String callExecutePlugin(String pluginId, JSONObject argsObj) {
         JSONObject targetPlugin = null;
         JSONObject targetPlugin = null;
         try {
         try {
@@ -590,6 +665,17 @@ public class McpServer extends NanoHTTPD {
         public String inverse(String matrix)      { return symjaEngine.matrixOperation("Inverse", matrix); }
         public String inverse(String matrix)      { return symjaEngine.matrixOperation("Inverse", matrix); }
         public String transpose(String matrix)    { return symjaEngine.matrixOperation("Transpose", matrix); }
         public String transpose(String matrix)    { return symjaEngine.matrixOperation("Transpose", matrix); }
         public String eigenvalues(String matrix)  { return symjaEngine.matrixOperation("Eigenvalues", matrix); }
         public String eigenvalues(String matrix)  { return symjaEngine.matrixOperation("Eigenvalues", matrix); }
+
+        // Plotting
+        public String plot(String func, String var, String xMin, String xMax) {
+            return symjaEngine.plot(func, var, xMin, xMax);
+        }
+        public String parametricPlot(String xf, String yf, String t, String tMin, String tMax) {
+            return symjaEngine.parametricPlot(xf, yf, t, tMin, tMax);
+        }
+        public String listPlot(String points) {
+            return symjaEngine.listPlot(points);
+        }
     }
     }
 
 
     // ── resources/list ────────────────────────────────────────────
     // ── resources/list ────────────────────────────────────────────

+ 224 - 0
app/src/main/java/com/nonvioce/ijs/SymjaEngine.java

@@ -605,4 +605,228 @@ public class SymjaEngine {
             return "错误: " + e.getMessage();
             return "错误: " + e.getMessage();
         }
         }
     }
     }
+
+    // ── 绘图 ─────────────────────────────────────────────────
+
+    /** 执行绘图表达式并解析为结构化 JSON */
+    public String evaluatePlot(String expression) {
+        String result = evaluate(expression);
+        if (result != null && result.startsWith("Graphics(")) {
+            return parseGraphicsToJson(result);
+        }
+        return result;
+    }
+
+    /** 直接绘图: Plot[f[x], {x, xMin, xMax}] */
+    public String plot(String function, String variable, String xMin, String xMax) {
+        String expr = "Plot[" + function + ", {" + variable + ", " + xMin + ", " + xMax + "}]";
+        return evaluatePlot(expr);
+    }
+
+    /** 参数方程绘图 */
+    public String parametricPlot(String xFunc, String yFunc, String tVar, String tMin, String tMax) {
+        String expr = "ParametricPlot[{" + xFunc + ", " + yFunc + "}, {" + tVar + ", " + tMin + ", " + tMax + "}]";
+        return evaluatePlot(expr);
+    }
+
+    /** 列表散点绘图 */
+    public String listPlot(String points) {
+        String expr = "ListPlot[" + points + "]";
+        return evaluatePlot(expr);
+    }
+
+    /**
+     * 解析 Symja 的 Graphics(...) 文本输出为绘图 JSON。
+     * 提取 Line/Point 序列、颜色、坐标轴选项。
+     */
+    private String parseGraphicsToJson(String graphics) {
+        try {
+            org.json.JSONObject json = new org.json.JSONObject();
+            json.put("type", "plot");
+            org.json.JSONArray elements = new org.json.JSONArray();
+
+            // 在 Graphics 字符串中逐字符扫描,搜索 Line( / Point( / RGBColor(
+            double[] currentColor = {0.368, 0.507, 0.710};
+            int pos = 0;
+            int len = graphics.length();
+
+            while (pos < len) {
+                pos = skipWhitespace(graphics, pos);
+                if (pos >= len) break;
+
+                if (matchToken(graphics, pos, "RGBColor")) {
+                    int pStart = graphics.indexOf('(', pos);
+                    int pEnd = findMatching(graphics, pStart);
+                    if (pEnd < 0) { pos++; continue; }
+                    String colorStr = graphics.substring(pStart + 1, pEnd);
+                    String[] parts = colorStr.split(",");
+                    if (parts.length == 3) {
+                        currentColor = new double[]{
+                            Double.parseDouble(parts[0].trim()),
+                            Double.parseDouble(parts[1].trim()),
+                            Double.parseDouble(parts[2].trim())
+                        };
+                    }
+                    pos = pEnd + 1;
+                } else if (matchToken(graphics, pos, "Line")) {
+                    int pStart = graphics.indexOf('(', pos);
+                    int pEnd = findMatching(graphics, pStart);
+                    if (pEnd < 0) { pos++; continue; }
+                    String lineStr = graphics.substring(pStart + 1, pEnd);
+                    org.json.JSONArray pts = parsePointList(lineStr);
+                    if (pts != null && pts.length() > 0) {
+                        org.json.JSONObject el = new org.json.JSONObject();
+                        el.put("type", "line");
+                        el.put("points", pts);
+                        el.put("color", colorToJson(currentColor));
+                        elements.put(el);
+                    }
+                    pos = pEnd + 1;
+                } else if (matchToken(graphics, pos, "Point")) {
+                    int pStart = graphics.indexOf('(', pos);
+                    int pEnd = findMatching(graphics, pStart);
+                    if (pEnd < 0) { pos++; continue; }
+                    String ptStr = graphics.substring(pStart + 1, pEnd);
+                    org.json.JSONArray pts = parsePointList(ptStr);
+                    if (pts != null && pts.length() > 0) {
+                        org.json.JSONObject el = new org.json.JSONObject();
+                        el.put("type", "point");
+                        el.put("points", pts);
+                        el.put("color", colorToJson(currentColor));
+                        elements.put(el);
+                    }
+                    pos = pEnd + 1;
+                } else {
+                    pos++;
+                }
+            }
+
+            json.put("elements", elements);
+            json.put("options", parsePlotOptions(graphics));
+
+            return json.toString();
+        } catch (Exception e) {
+            return graphics;
+        }
+    }
+
+    /** 将 Symja 的 *10^ 科学记数法转换为 Java 可解析的 E 格式 */
+    private double parseSciDouble(String s) {
+        // Symja 输出: -2.44929*10^-16 或 1.5*10^3
+        // Java 格式: -2.44929E-16 或 1.5E3
+        s = s.replace("*10^", "E");
+        return Double.parseDouble(s);
+    }
+
+    /** 检查 s 在 pos 位置是否匹配 token(token 后必须跟非字母数字字符) */
+    private boolean matchToken(String s, int pos, String token) {
+        if (!s.startsWith(token, pos)) return false;
+        int next = pos + token.length();
+        return next >= s.length() || !Character.isLetterOrDigit(s.charAt(next));
+    }
+
+    private int findMatchingBrace(String s, int openPos) {
+        int depth = 0;
+        for (int i = openPos; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c == '{') depth++;
+            else if (c == '}') {
+                depth--;
+                if (depth == 0) return i;
+            }
+        }
+        return -1;
+    }
+
+    private int findMatching(String s, int openPos) {
+        int depth = 0;
+        for (int i = openPos; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c == '(') depth++;
+            else if (c == ')') {
+                depth--;
+                if (depth == 0) return i;
+            }
+        }
+        return -1;
+    }
+
+    private int skipWhitespace(String s, int pos) {
+        while (pos < s.length() && (s.charAt(pos) == ' ' || s.charAt(pos) == '\n' || s.charAt(pos) == '\r' || s.charAt(pos) == '\t')) {
+            pos++;
+        }
+        return pos;
+    }
+
+    private org.json.JSONArray parsePointList(String str) {
+        try {
+            // 格式: {{x1,y1},{x2,y2},...}
+            // 先剥离外层大括号
+            str = str.trim();
+            if (str.startsWith("{{")) {
+                int end = findMatchingBrace(str, 0);
+                if (end > 0) str = str.substring(1, end);
+            }
+            // 现在格式: {x1,y1},{x2,y2},...
+            org.json.JSONArray result = new org.json.JSONArray();
+            int pos = 0;
+            while (pos < str.length()) {
+                pos = skipWhitespace(str, pos);
+                if (pos >= str.length()) break;
+                if (str.charAt(pos) == '{') {
+                    int end = findMatchingBrace(str, pos);
+                    if (end < 0) break;
+                    String pair = str.substring(pos + 1, end);
+                    String[] xy = pair.split(",");
+                    if (xy.length >= 2) {
+                        org.json.JSONArray pt = new org.json.JSONArray();
+                        pt.put(parseSciDouble(xy[0].trim()));
+                        pt.put(parseSciDouble(xy[1].trim()));
+                        result.put(pt);
+                    }
+                    pos = end + 1;
+                } else if (str.charAt(pos) == ',') {
+                    pos++;
+                } else {
+                    pos++;
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            return new org.json.JSONArray();
+        }
+    }
+
+    private org.json.JSONArray colorToJson(double[] c) {
+        try {
+            org.json.JSONArray arr = new org.json.JSONArray();
+            arr.put(c[0]);
+            arr.put(c[1]);
+            arr.put(c[2]);
+            return arr;
+        } catch (org.json.JSONException e) {
+            return new org.json.JSONArray();
+        }
+    }
+
+    private org.json.JSONObject parsePlotOptions(String graphics) {
+        org.json.JSONObject opts = new org.json.JSONObject();
+        try {
+            // 在第二个 } 之后解析 Axes->True 等选项
+            int firstBrace = graphics.indexOf('}');
+            if (firstBrace < 0) return opts;
+            int secondBrace = findMatchingBrace(graphics, graphics.indexOf('{', firstBrace));
+            if (secondBrace < 0) secondBrace = firstBrace;
+            // 回退到 Graphics( 的 { 之后的大括号
+            int bodyEnd = graphics.indexOf(')', secondBrace);
+            if (bodyEnd < 0) bodyEnd = graphics.length() - 1;
+
+            String tail = graphics.substring(secondBrace + 1);
+            // 解析 Axes->True/False
+            opts.put("axes", tail.contains("Axes->True") || !tail.contains("Axes->False"));
+            // 解析 Joined
+            opts.put("joined", tail.contains("Joined->True"));
+        } catch (Exception ignored) {}
+        return opts;
+    }
 }
 }

+ 0 - 0
gradlew