Эх сурвалжийг харах

MCP 绘图工具改为返回结构化数据

原来三个绘图工具返回 Symja 原始的 Graphics(...) 文本 —— parseGraphicsToJson
那 829 行在步骤①被裁掉了,当时没补回来。

补的方式和 Ijs 不同:**不移植那个字符串扫描器**,改成遍历 Symja 返回的
表达式树。结构很规整:

  Graphics( {RGBColor[...], Line[{{x,y},...]}, Point[{...}], ...}, Rule[...], ... )

遍历第一个 List 参数里的图元,记住最近一个 RGBColor 作为其后图元的颜色,
再把尾部的 Rule 参数收成 options。比扫描文本稳得多,也不需要那 829 行。

返回形如:
  {"type":"plot",
   "elements":[{"type":"line","color":"#5D81B4","points":[[x,y],...]}],
   "options":{"Axes":"True",...}}

真机验证(Android 14,经 MCP 直连):
  plot Sin[x] over [0,6]        -> 1 个 line 图元 / 65 点
  list_plot {{1,2},{3,4},{5,1}} -> 1 个 point 图元 / 3 点
  parametric_plot 单位圆         -> 1 个 line 图元 / 1201 点,首点 [1,0]

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 4 долоо хоног өмнө
parent
commit
a29fe49cde

+ 11 - 8
android/app/src/main/java/com/nonvioce/cal/McpServer.java

@@ -273,9 +273,9 @@ public class McpServer extends NanoHTTPD {
 
         // --- Plotting ---
         tools.put(tool("plot",
-            "Plot a function f[x] over a range. Returns Symja's raw Graphics(...) " +
-            "text rather than structured data — this server has no plot-data " +
-            "conversion (the app draws curves from its own sampling). Use Symja syntax.",
+            "Plot a function f[x] over a range. Returns structured plot data as JSON: " +
+            "{type:'plot', elements:[{type:'line'|'point', color:'#RRGGBB', " +
+            "points:[[x,y],...]}], options:{...}}. Use Symja math syntax.",
             inputSchema(
                 props()
                     .put("function", stringProp("Function expression, e.g. Sin[x] or x^2"))
@@ -285,7 +285,8 @@ public class McpServer extends NanoHTTPD {
                 "function", "variable", "x_min", "x_max")));
 
         tools.put(tool("parametric_plot",
-            "Plot a parametric curve (x(t), y(t)). Returns Symja's raw Graphics(...) text.",
+            "Plot a parametric curve (x(t), y(t)). Returns structured plot data as JSON " +
+            "(same shape as the plot tool).",
             inputSchema(
                 props()
                     .put("x_func", stringProp("x(t) expression, e.g. Sin[t]"))
@@ -296,7 +297,8 @@ public class McpServer extends NanoHTTPD {
                 "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 Symja's raw Graphics(...) text.",
+            "Plot a list of points as a scatter plot. Returns structured plot data as JSON " +
+            "(same shape as the plot tool, elements are of type 'point').",
             inputSchema(
                 props()
                     .put("points", stringProp(
@@ -454,16 +456,17 @@ public class McpServer extends NanoHTTPD {
 
     private String callPlot(String function, String variable, String xMin, String xMax) {
         String expr = "Plot[" + function + ", {" + variable + ", " + xMin + ", " + xMax + "}]";
-        return SymjaBridge.INSTANCE.evaluateSync(expr, EVAL_TIMEOUT_MS);
+        // plotSync 会把 Symja 的 Graphics 表达式树转成 {type:"plot", elements:[...]} JSON
+        return SymjaBridge.INSTANCE.plotSync(expr, EVAL_TIMEOUT_MS);
     }
 
     private String callParametricPlot(String xFunc, String yFunc, String tVar, String tMin, String tMax) {
         String expr = "ParametricPlot[{" + xFunc + ", " + yFunc + "}, {" + tVar + ", " + tMin + ", " + tMax + "}]";
-        return SymjaBridge.INSTANCE.evaluateSync(expr, EVAL_TIMEOUT_MS);
+        return SymjaBridge.INSTANCE.plotSync(expr, EVAL_TIMEOUT_MS);
     }
 
     private String callListPlot(String points) {
-        return SymjaBridge.INSTANCE.evaluateSync(
+        return SymjaBridge.INSTANCE.plotSync(
             "ListPlot[" + points + "]", EVAL_TIMEOUT_MS);
     }
 

+ 122 - 0
android/app/src/main/kotlin/com/nonvioce/cal/SymjaBridge.kt

@@ -10,6 +10,8 @@ import org.matheclipse.core.interfaces.IAST
 import org.matheclipse.core.interfaces.IExpr
 import org.matheclipse.core.interfaces.INum
 import org.matheclipse.core.interfaces.ISymbol
+import org.json.JSONArray
+import org.json.JSONObject
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
@@ -261,6 +263,126 @@ object SymjaBridge {
         }
     }
 
+    /**
+     * 求值一个绘图表达式,并把 Symja 返回的 `Graphics` **表达式树**转成 JSON。
+     *
+     * 返回形如:
+     * `{"type":"plot","elements":[{"type":"line","color":"#5E81B5","points":[[x,y],...]}],
+     *   "options":{"Axes":"True", ...}}`
+     *
+     * 刻意**不移植** Ijs 那套 829 行的 `parseGraphicsToJson` —— 它是扫描 `Graphics(...)`
+     * 文本,脆且难维护。这里直接走表达式树:
+     *   `Graphics( {RGBColor[...], Line[{{x,y},...]}, Point[{...}], ...}, Rule[...], ... )`
+     * 遍历时记住最近一个 RGBColor,作为其后图元的颜色。
+     */
+    fun plotSync(expr: String, timeoutMs: Long): String {
+        val f = worker.submit<String> {
+            try {
+                val r = evaluator.evaluateWithTimeout(
+                    expr, timeoutMs, TimeUnit.MILLISECONDS, true, null
+                )
+                if (r == null) {
+                    "Error: 绘图超时(${timeoutMs}ms)或被取消"
+                } else {
+                    graphicsToJson(r) ?: r.toString()   // 不是 Graphics 就原样返回
+                }
+            } catch (t: Throwable) {
+                "Error: " + (failure(t)["error"] ?: t.toString())
+            }
+        }
+        return try {
+            f.get(timeoutMs + 5_000, TimeUnit.MILLISECONDS)
+        } catch (t: Throwable) {
+            f.cancel(true)
+            "Error: " + t.javaClass.simpleName
+        }
+    }
+
+    /** 把 `Graphics(...)` 表达式树转成 JSON;不是 Graphics 就返回 null。 */
+    private fun graphicsToJson(g: IExpr): String? {
+        if (g !is IAST || !g.head().toString().equals("Graphics", ignoreCase = true)) return null
+        if (g.size() < 2) return null
+        val primitives = g.get(1)
+        if (primitives !is IAST) return null
+
+        var color: String? = null
+        val elements = JSONArray()
+        for (p in primitives) {
+            if (p !is IAST) continue
+            when (p.head().toString()) {
+                "RGBColor" -> color = rgbToHex(p)
+
+                "Line" -> pointsOf(p)?.let {
+                    elements.put(
+                        JSONObject()
+                            .put("type", "line")
+                            .put("color", color ?: "#000000")
+                            .put("points", it)
+                    )
+                }
+
+                "Point" -> pointsOf(p)?.let {
+                    elements.put(
+                        JSONObject()
+                            .put("type", "point")
+                            .put("color", color ?: "#000000")
+                            .put("points", it)
+                    )
+                }
+            }
+        }
+
+        // Graphics 的第 2 个参数起是 Rule 形式的选项(Axes->True 之类)
+        val options = JSONObject()
+        for (i in 2 until g.size()) {
+            val rule = g.get(i)
+            if (rule is IAST && rule.head().toString() == "Rule" && rule.size() >= 3) {
+                options.put(rule.get(1).toString(), rule.get(2).toString())
+            }
+        }
+
+        return JSONObject()
+            .put("type", "plot")
+            .put("elements", elements)
+            .put("options", options)
+            .toString()
+    }
+
+    /** `Line[{{x,y},...}]` / `Point[{...}]` 里的坐标列表。 */
+    private fun pointsOf(prim: IAST): JSONArray? {
+        if (prim.size() < 2) return null
+        val list = prim.get(1)
+        if (list !is IAST) return null
+        val out = JSONArray()
+        for (pt in list) {
+            if (pt !is IAST || pt.size() < 3) continue
+            val x = pt.get(1).asDouble() ?: continue
+            val y = pt.get(2).asDouble() ?: continue
+            out.put(JSONArray().put(x).put(y))
+        }
+        return if (out.length() == 0) null else out
+    }
+
+    private fun rgbToHex(c: IAST): String? {
+        if (c.size() < 4) return null
+        val r = c.get(1).asDouble() ?: return null
+        val g = c.get(2).asDouble() ?: return null
+        val b = c.get(3).asDouble() ?: return null
+        return String.format(
+            "#%02X%02X%02X",
+            (r * 255).toInt().coerceIn(0, 255),
+            (g * 255).toInt().coerceIn(0, 255),
+            (b * 255).toInt().coerceIn(0, 255),
+        )
+    }
+
+    /** 数值表达式转 double;非数值(ComplexInfinity、符号等)返回 null。 */
+    private fun IExpr.asDouble(): Double? = try {
+        if (this is INum) doubleValue() else toString().toDoubleOrNull()
+    } catch (t: Throwable) {
+        null
+    }
+
     /** 同步取当前全局变量的 `名字 = 值` 文本,一行一个。 */
     fun variablesSync(): String {
         val f = worker.submit<String> {