Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
.DS_Store
/build
/captures
/temp/
.externalNativeBuild
.cxx
local.properties
Expand All @@ -33,4 +34,4 @@ feature-tool/build/*
markdown/build/*
ui-theme/build/*
tool-ui/build/*
.omo/
.omo/
15 changes: 13 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
alias(libs.plugins.android.application)
}

val releaseVersionName = "1.2.6"
val releaseVersionName = "1.2.8-max"
val releaseApkName = "LineCode Pro $releaseVersionName.APK"
val releaseIdsigName = "$releaseApkName.idsig"
val releaseSigningProperties = Properties()
Expand Down Expand Up @@ -92,6 +92,16 @@ val validateReleaseSigning by tasks.registering {
}

android {
testOptions {
unitTests.isIncludeAndroidResources = true
unitTests.all {
it.jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED",
"--add-opens=java.base/java.util=ALL-UNNAMED",
"--add-opens=java.base/java.io=ALL-UNNAMED",
"--add-opens=java.base/jdk.internal.access=ALL-UNNAMED")
}
}

namespace = "cn.lineai"
compileSdk {
version = release(36) {
Expand All @@ -103,7 +113,7 @@ android {
applicationId = "cn.lineai"
minSdk = 26
targetSdk = 37
versionCode = 31
versionCode = 32
versionName = releaseVersionName
}

Expand Down Expand Up @@ -237,6 +247,7 @@ dependencies {
implementation(project(":ui-theme"))
implementation(project(":markdown"))
implementation(project(":tool-ui"))
testImplementation(libs.robolectric)
testImplementation(libs.junit)
testImplementation(libs.json)
}
35 changes: 25 additions & 10 deletions app/src/main/java/cn/lineai/ai/prompt/ToolPromptRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
import java.util.Set;

public class ToolPromptRenderer {
private static final String PROCESSING_BOUNDARY_RULE = "\nAfter completing the tool work, emit the exact internal marker <LEOF \"完成了任务重写\"> once, immediately before the final answer. "
+ "The app hides this marker, closes the processing section, and displays subsequent text as the final answer. "
+ "Never emit a separate <L prefix, put the marker in a tool call, or call tools after the marker.";

public static String renderToolPrompt(
List<McpToolConfig> configs,
Expand Down Expand Up @@ -42,7 +45,7 @@ public static String renderToolPrompt(
}
StringBuilder builder = new StringBuilder();
builder.append("## Available Tools\nThe following tool list is dynamically generated from current MCP settings, permission mode, execution target, and registered tools. Unlisted tools are unavailable; tool execution must comply with the current permission mode.\n\n");
List<McpToolConfig> promptConfigs = configs == null ? new ArrayList<>() : configs;
List<McpToolConfig> promptConfigs = orderedConfigs(configs);
HashSet<String> renderedTools = new HashSet<>();
for (McpToolConfig config : promptConfigs) {
ArrayList<String> tools = new ArrayList<>();
Expand All @@ -55,18 +58,19 @@ public static String renderToolPrompt(
if (tools.isEmpty()) {
continue;
}
java.util.Collections.sort(tools);
builder.append("### ").append(config.getName()).append('\n');
for (String toolName : tools) {
ToolInfo tool = toolByName == null ? null : toolByName.get(toolName);
builder.append(" - ").append(toolName);
if (tool != null) {
builder.append(" [").append(categoryLabel(tool.getCategory()));
if (tool.needsConfirmation()) {
builder.append(", needs confirmation");
builder.append(", confirmation depends on permission mode");
}
builder.append("]: ").append(tool.getDescription()).append('\n');
try {
builder.append(" Parameters: ").append(tool.getParameters().toString()).append('\n');
builder.append(" Parameters: ").append(StableJson.stringify(tool.getParameters())).append('\n');
} catch (Exception ignored) {
builder.append(" Parameters: {}\n");
}
Expand All @@ -79,10 +83,11 @@ public static String renderToolPrompt(
appendExtensionTools(builder, enabled, renderedTools, toolByName);
if (nativeToolProtocol) {
builder.append("Tool calls are provided by the current model protocol's native tools/function calling mechanism. When you need to read, write, search, generate images, or list directories, you must use native tool calls; do not output tool call JSON, XML, <tool_calls>, or Markdown code blocks in the response text.")
.append("After each tool returns, you must continue analyzing the result; if the task is not yet complete, continue calling appropriate tools for the next step.");
.append(PROCESSING_BOUNDARY_RULE);
} else {
builder.append("Tool call format is locked: when you need to call a tool, you must output <tool_calls><tool_call name=\"tool_name\"><argument name=\"param_name\">value</argument></tool_calls>.")
.append("Do not output OpenAI tool_calls JSON, Markdown code blocks, or natural language wrappers. After each tool returns, you must continue analyzing the result; if the task is not yet complete, continue calling appropriate tools for the next step.");
.append("Do not output OpenAI tool_calls JSON, Markdown code blocks, or natural language wrappers.")
.append(PROCESSING_BOUNDARY_RULE);
}
return builder.toString().trim();
}
Expand Down Expand Up @@ -112,7 +117,7 @@ private static String renderRemoteToolPrompt(
.append("Do not reference the app's private home working directory; if the system prompt provides a terminal provider working directory, you must operate within that directory.\n")
.append("To read, write, list directories, or search files, use shell commands within the terminal provider environment.\n\n");
}
List<McpToolConfig> promptConfigs = configs == null ? new ArrayList<>() : configs;
List<McpToolConfig> promptConfigs = orderedConfigs(configs);
HashSet<String> renderedTools = new HashSet<>();
for (McpToolConfig config : promptConfigs) {
ArrayList<String> tools = new ArrayList<>();
Expand All @@ -125,18 +130,19 @@ private static String renderRemoteToolPrompt(
if (tools.isEmpty()) {
continue;
}
java.util.Collections.sort(tools);
builder.append("### ").append(config.getName()).append('\n');
for (String toolName : tools) {
ToolInfo tool = toolByName == null ? null : toolByName.get(toolName);
builder.append(" - ").append(toolName);
if (tool != null) {
builder.append(" [").append(categoryLabel(tool.getCategory()));
if (tool.needsConfirmation()) {
builder.append(", needs confirmation");
builder.append(", confirmation depends on permission mode");
}
builder.append("]: ").append(tool.getDescription()).append('\n');
try {
builder.append(" Parameters: ").append(tool.getParameters().toString()).append('\n');
builder.append(" Parameters: ").append(StableJson.stringify(tool.getParameters())).append('\n');
} catch (Exception ignored) {
builder.append(" Parameters: {}\n");
}
Expand All @@ -159,6 +165,7 @@ private static String renderRemoteToolPrompt(
builder.append("Tool call format is locked: when you need to call a tool, you must output <tool_calls><tool_call name=\"tool_name\"><argument name=\"param_name\">value</argument></tool_calls>.")
.append("Do not output OpenAI tool_calls JSON, Markdown code blocks, or natural language wrappers.");
}
builder.append(PROCESSING_BOUNDARY_RULE);
return builder.toString().trim();
}

Expand Down Expand Up @@ -186,7 +193,7 @@ private static void appendExtensionTools(
builder.append(" [").append(categoryLabel(tool.getCategory())).append("]: ")
.append(tool.getDescription()).append('\n');
try {
builder.append(" Parameters: ").append(tool.getParameters().toString()).append('\n');
builder.append(" Parameters: ").append(StableJson.stringify(tool.getParameters())).append('\n');
} catch (Exception ignored) {
builder.append(" Parameters: {}\n");
}
Expand Down Expand Up @@ -219,7 +226,9 @@ private static String findToolSupplement(
if (config == null || toolByName == null || toolByName.isEmpty()) {
return null;
}
for (String toolName : config.getTools()) {
ArrayList<String> toolNames = new ArrayList<>(java.util.Arrays.asList(config.getTools()));
java.util.Collections.sort(toolNames);
for (String toolName : toolNames) {
ToolInfo tool = toolByName.get(toolName);
if (tool != null) {
String supplement = tool.promptSupplement(executionMode, isSsh);
Expand All @@ -230,4 +239,10 @@ private static String findToolSupplement(
}
return null;
}

private static List<McpToolConfig> orderedConfigs(List<McpToolConfig> configs) {
ArrayList<McpToolConfig> ordered = configs == null ? new ArrayList<>() : new ArrayList<>(configs);
ordered.sort(java.util.Comparator.comparing(McpToolConfig::getId));
return ordered;
}
}
19 changes: 17 additions & 2 deletions app/src/main/java/cn/lineai/ai/prompt/ToolPromptService.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public String buildToolPrompt(Set<String> implementedToolNames, boolean nativeTo
if (implementedToolNames != null) {
enabled.retainAll(implementedToolNames);
}
return toolPromptRenderer.renderToolPrompt(collectEnabledTools(enabled), nativeToolProtocol);
return withPermissionMode(toolPromptRenderer.renderToolPrompt(collectEnabledTools(enabled), nativeToolProtocol));
}

public String buildToolPrompt(Collection<ToolInfo> implementedTools, boolean nativeToolProtocol) {
Expand All @@ -50,7 +50,21 @@ public String buildToolPrompt(Collection<ToolInfo> implementedTools, boolean nat
}
}
}
return toolPromptRenderer.renderToolPrompt(enabledTools, nativeToolProtocol);
enabledTools.sort(java.util.Comparator.comparing(ToolInfo::getName));
return withPermissionMode(toolPromptRenderer.renderToolPrompt(enabledTools, nativeToolProtocol));
}

private String withPermissionMode(String prompt) {
String mode = toolSettingsStore.getPermissionMode();
if (ToolSettingsStore.PERMISSION_AUTO.equals(mode)) {
return "Permission mode: automatic. Enabled tools execute without per-call confirmation. "
+ "Submit tool calls directly instead of asking the user for execution approval.\n\n" + prompt;
}
if (ToolSettingsStore.PERMISSION_CONFIRM.equals(mode)) {
return "Permission mode: confirmation. Submit tool calls directly; the app will request approval "
+ "for tools that require it before execution.\n\n" + prompt;
}
return prompt;
}

private boolean isEnabledExtensionTool(String toolName, ToolCategory category) {
Expand Down Expand Up @@ -84,6 +98,7 @@ private List<ToolInfo> collectEnabledTools(Set<String> enabledNames) {
}
}
}
tools.sort(java.util.Comparator.comparing(ToolInfo::getName));
return tools;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cn.lineai.data.repository;

import cn.lineai.model.tool.ToolCall;
import cn.lineai.tool.ToolNames;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.json.JSONArray;
import org.json.JSONObject;

/** Exact command grants; a grant never changes tool availability or read-only policy. */
public final class CommandPermissionRepository {
private static final String KEY = "@linecode_command_grants_v1";
private final SettingsStore settings;
public CommandPermissionRepository(SettingsStore settings) { this.settings = settings; }

public synchronized boolean isAllowed(String scope, ToolCall call) {
String key = grantKey(scope, call);
if (key.isEmpty()) return false;
JSONArray grants = read();
for (int i = 0; i < grants.length(); i++) if (key.equals(grants.optString(i))) return true;
return false;
}
public synchronized void allow(String scope, ToolCall call) {
String key = grantKey(scope, call);
if (key.isEmpty() || isAllowed(scope, call)) return;
JSONArray grants = read(), next = new JSONArray();
// Keep the newest grants if a workspace has accumulated a large number of commands.
for (int i = Math.max(0, grants.length() - 511); i < grants.length(); i++) next.put(grants.optString(i));
next.put(key); settings.setString(KEY, next.toString());
}
public synchronized void clear() { settings.remove(KEY); }
private JSONArray read() {
try { return new JSONArray(settings.getString(KEY, "[]")); }
catch (Exception ignored) { return new JSONArray(); }
}
public static String grantKey(String scope, ToolCall call) {
if (scope == null || scope.isEmpty() || call == null || !ToolNames.SHELL_EXECUTE.equals(call.getName())) return "";
try {
JSONObject input = new JSONObject(call.getArguments());
if (!(input.opt("command") instanceof String) || input.getString("command").trim().isEmpty()) return "";
String command = input.getString("command");
String cwd = input.optString("cwd", "").trim();
// JSON framing prevents delimiter ambiguity; command bytes are not normalized.
String value = new JSONArray().put(scope).put(call.getName()).put(command).put(cwd).toString();
byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder key = new StringBuilder();
for (byte b : hash) key.append(String.format(java.util.Locale.ROOT, "%02x", b & 255));
return key.toString();
} catch (Exception ignored) { return ""; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ public synchronized SkillRecord installSkillFromGitHub(String homePath, String l
return skillRepository.installSkillFromGitHub(homePath, location, githubUrl);
}

@Override
public synchronized SkillRecord installSkillFromSkillHub(String homePath, String location, String slug, String version) throws Exception {
return skillRepository.installSkillFromSkillHub(homePath, location, slug, version);
}

@Override
public synchronized void setSkillEnabled(String id, boolean enabled) {
skillRepository.setSkillEnabled(id, enabled);
Expand Down
55 changes: 48 additions & 7 deletions app/src/main/java/cn/lineai/data/repository/SkillRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import cn.lineai.data.db.LineCodeDatabase;
import cn.lineai.data.service.GitHubSkillInstaller;
import cn.lineai.data.service.SkillFileManager;
import cn.lineai.data.service.SkillHubClient;
import cn.lineai.model.ExtensionAgentConfig;
import cn.lineai.model.ExtensionMcpConfig;
import cn.lineai.model.McpToolSummary;
Expand Down Expand Up @@ -106,11 +107,12 @@ public synchronized SkillRecord installSkillFromUri(String homePath, String loca
File tempDir = fileManager.uniqueChild(tempRoot, fileManager.stripExtension(fileName));
File tempFile = new File(tempDir, fileName.toLowerCase(Locale.ROOT).endsWith(".zip") ? fileName : "SKILL.md");
fileManager.copyUriToFile(uri, tempFile);
try {
return installSkill(homePath, location, tempFile.getAbsolutePath(), fileManager.stripExtension(fileName));
} finally {
fileManager.deleteRecursive(tempDir);
}
return installTemporarySkill(
homePath,
location,
tempFile,
fileManager.stripExtension(fileName),
tempDir);
}

public synchronized SkillRecord installSkillFromGitHub(String homePath, String location, String githubUrl) throws Exception {
Expand All @@ -121,10 +123,45 @@ public synchronized SkillRecord installSkillFromGitHub(String homePath, String l
fileManager
);
File downloaded = installer.downloadToTemp(githubUrl);
return installTemporarySkill(
homePath,
location,
downloaded,
downloaded.getName(),
downloaded);
}

public synchronized SkillRecord installSkillFromSkillHub(
String homePath,
String location,
String slug,
String version
) throws Exception {
fileManager.ensureSkillRoots(homePath);
File tempRoot = new File(fileManager.getWorkspacePaths().getLinecodeRoot(), "tmp/skills-skillhub");
File tempDir = fileManager.uniqueChild(tempRoot, fileManager.sanitizeFileName(slug));
File archive = new File(tempDir, "skill.zip");
byte[] bytes = new SkillHubClient(resourceProvider).download(slug, version);
try {
fileManager.writeBytes(archive, bytes);
} catch (RuntimeException error) {
fileManager.deleteRecursive(tempDir);
throw error;
}
return installTemporarySkill(homePath, location, archive, slug, tempDir);
}

private SkillRecord installTemporarySkill(
String homePath,
String location,
File source,
String name,
File cleanupTarget
) throws Exception {
try {
return installSkill(homePath, location, downloaded.getAbsolutePath(), downloaded.getName());
return installSkill(homePath, location, source.getAbsolutePath(), name);
} finally {
fileManager.deleteRecursive(downloaded);
fileManager.deleteRecursive(cleanupTarget);
}
}

Expand Down Expand Up @@ -163,6 +200,7 @@ public synchronized String buildExtensionPrompt(String homePath) {
enabledAgents.add(agent);
}
}
enabledAgents.sort(java.util.Comparator.comparing(ExtensionAgentConfig::getId));
if (!enabledAgents.isEmpty()) {
hasContent = true;
builder.append("\n### 自定义 Agent\n");
Expand All @@ -185,6 +223,7 @@ public synchronized String buildExtensionPrompt(String homePath) {
enabledMcps.add(mcp);
}
}
enabledMcps.sort(java.util.Comparator.comparing(ExtensionMcpConfig::getId));
if (!enabledMcps.isEmpty()) {
hasContent = true;
builder.append("\n### 自定义 HTTP MCP\n");
Expand All @@ -200,6 +239,7 @@ public synchronized String buildExtensionPrompt(String homePath) {
enabledSkills.add(skill);
}
}
enabledSkills.sort(java.util.Comparator.comparing(SkillRecord::getId));
if (!enabledSkills.isEmpty()) {
hasContent = true;
builder.append("\n### 已安装 Skills\n");
Expand Down Expand Up @@ -227,6 +267,7 @@ private String enabledToolNames(ExtensionMcpConfig mcp) {
names.add(tool.getName());
}
}
java.util.Collections.sort(names);
return join(names, ", ", "未启用 tools");
}

Expand Down
Loading
Loading