diff --git a/.gitignore b/.gitignore index bd2dd2f0..097c1399 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .DS_Store /build /captures +/temp/ .externalNativeBuild .cxx local.properties @@ -33,4 +34,4 @@ feature-tool/build/* markdown/build/* ui-theme/build/* tool-ui/build/* -.omo/ \ No newline at end of file +.omo/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb887469..42e974d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() @@ -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) { @@ -103,7 +113,7 @@ android { applicationId = "cn.lineai" minSdk = 26 targetSdk = 37 - versionCode = 31 + versionCode = 32 versionName = releaseVersionName } @@ -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) } diff --git a/app/src/main/java/cn/lineai/ai/prompt/ToolPromptRenderer.java b/app/src/main/java/cn/lineai/ai/prompt/ToolPromptRenderer.java index ae08956e..eaedf451 100644 --- a/app/src/main/java/cn/lineai/ai/prompt/ToolPromptRenderer.java +++ b/app/src/main/java/cn/lineai/ai/prompt/ToolPromptRenderer.java @@ -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 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 configs, @@ -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 promptConfigs = configs == null ? new ArrayList<>() : configs; + List promptConfigs = orderedConfigs(configs); HashSet renderedTools = new HashSet<>(); for (McpToolConfig config : promptConfigs) { ArrayList tools = new ArrayList<>(); @@ -55,6 +58,7 @@ 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); @@ -62,11 +66,11 @@ public static String renderToolPrompt( 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"); } @@ -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, , 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 value.") - .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(); } @@ -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 promptConfigs = configs == null ? new ArrayList<>() : configs; + List promptConfigs = orderedConfigs(configs); HashSet renderedTools = new HashSet<>(); for (McpToolConfig config : promptConfigs) { ArrayList tools = new ArrayList<>(); @@ -125,6 +130,7 @@ 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); @@ -132,11 +138,11 @@ private static String renderRemoteToolPrompt( 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"); } @@ -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 value.") .append("Do not output OpenAI tool_calls JSON, Markdown code blocks, or natural language wrappers."); } + builder.append(PROCESSING_BOUNDARY_RULE); return builder.toString().trim(); } @@ -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"); } @@ -219,7 +226,9 @@ private static String findToolSupplement( if (config == null || toolByName == null || toolByName.isEmpty()) { return null; } - for (String toolName : config.getTools()) { + ArrayList 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); @@ -230,4 +239,10 @@ private static String findToolSupplement( } return null; } + + private static List orderedConfigs(List configs) { + ArrayList ordered = configs == null ? new ArrayList<>() : new ArrayList<>(configs); + ordered.sort(java.util.Comparator.comparing(McpToolConfig::getId)); + return ordered; + } } diff --git a/app/src/main/java/cn/lineai/ai/prompt/ToolPromptService.java b/app/src/main/java/cn/lineai/ai/prompt/ToolPromptService.java index 5da966e4..f57db94d 100644 --- a/app/src/main/java/cn/lineai/ai/prompt/ToolPromptService.java +++ b/app/src/main/java/cn/lineai/ai/prompt/ToolPromptService.java @@ -35,7 +35,7 @@ public String buildToolPrompt(Set 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 implementedTools, boolean nativeToolProtocol) { @@ -50,7 +50,21 @@ public String buildToolPrompt(Collection 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) { @@ -84,6 +98,7 @@ private List collectEnabledTools(Set enabledNames) { } } } + tools.sort(java.util.Comparator.comparing(ToolInfo::getName)); return tools; } } diff --git a/app/src/main/java/cn/lineai/data/repository/CommandPermissionRepository.java b/app/src/main/java/cn/lineai/data/repository/CommandPermissionRepository.java new file mode 100644 index 00000000..764f7dee --- /dev/null +++ b/app/src/main/java/cn/lineai/data/repository/CommandPermissionRepository.java @@ -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 ""; } + } +} diff --git a/app/src/main/java/cn/lineai/data/repository/ExtensionRepository.java b/app/src/main/java/cn/lineai/data/repository/ExtensionRepository.java index f17c0f55..2c61b93a 100644 --- a/app/src/main/java/cn/lineai/data/repository/ExtensionRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/ExtensionRepository.java @@ -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); diff --git a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java index b42b0ea1..b3f05c1d 100644 --- a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java @@ -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; @@ -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 { @@ -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); } } @@ -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"); @@ -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"); @@ -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"); @@ -227,6 +267,7 @@ private String enabledToolNames(ExtensionMcpConfig mcp) { names.add(tool.getName()); } } + java.util.Collections.sort(names); return join(names, ", ", "未启用 tools"); } diff --git a/app/src/main/java/cn/lineai/data/repository/ToolSettingsRepository.java b/app/src/main/java/cn/lineai/data/repository/ToolSettingsRepository.java index f9cc2add..21524f72 100644 --- a/app/src/main/java/cn/lineai/data/repository/ToolSettingsRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/ToolSettingsRepository.java @@ -45,6 +45,7 @@ public final class ToolSettingsRepository implements ToolSettingsStore { private final ResourceProvider resourceProvider; private final SettingsRepository settingsRepository; + private final CommandPermissionRepository commandPermissions; private final WebSearchConfigRepository webSearchConfigRepository; private final PhoneControlRepository phoneControlRepository; private ToolRegistry toolRegistry; @@ -55,6 +56,7 @@ public final class ToolSettingsRepository implements ToolSettingsStore { public ToolSettingsRepository(ResourceProvider resourceProvider, SettingsRepository settingsRepository, WebSearchConfigRepository webSearchConfigRepository, PhoneControlRepository phoneControlRepository, ToolCategoryResolver categoryResolver) { this.resourceProvider = resourceProvider; this.settingsRepository = settingsRepository; + this.commandPermissions = new CommandPermissionRepository(settingsRepository); this.webSearchConfigRepository = webSearchConfigRepository; this.phoneControlRepository = phoneControlRepository; toolCategoryResolver = categoryResolver; @@ -119,6 +121,14 @@ private List buildDefaultConfigs() { return configs; } + @Override public boolean isCommandPermanentlyAllowed(String scope, cn.lineai.model.tool.ToolCall call) { + return commandPermissions.isAllowed(scope, call); + } + @Override public void allowCommandPermanently(String scope, cn.lineai.model.tool.ToolCall call) { + commandPermissions.allow(scope, call); + } + @Override public void clearPermanentCommandPermissions() { commandPermissions.clear(); } + public void setToolRegistry(ToolRegistry toolRegistry) { this.toolRegistry = toolRegistry; } diff --git a/app/src/main/java/cn/lineai/data/service/ContextResourceProvider.java b/app/src/main/java/cn/lineai/data/service/ContextResourceProvider.java new file mode 100644 index 00000000..3736091a --- /dev/null +++ b/app/src/main/java/cn/lineai/data/service/ContextResourceProvider.java @@ -0,0 +1,39 @@ +package cn.lineai.data.service; + +import android.content.Context; +import cn.lineai.resource.ResourceProvider; +import java.io.InputStream; + +/** + * ResourceProvider backed by Android Context. + * + * Lets data-layer classes (services/repositories) read string resources without + * holding a Context themselves: the UI layer constructs this provider and + * injects it, mirroring {@code MainDependencies}' wiring pattern. + */ +public final class ContextResourceProvider implements ResourceProvider { + private final Context context; + + public ContextResourceProvider(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public InputStream openAsset(String path) { + try { + return context.getAssets().open(path); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getString(int resId) { + return context.getString(resId); + } + + @Override + public String getString(int resId, Object... formatArgs) { + return context.getString(resId, formatArgs); + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/lineai/data/service/SkillFileManager.java b/app/src/main/java/cn/lineai/data/service/SkillFileManager.java index d6adf840..011666ba 100644 --- a/app/src/main/java/cn/lineai/data/service/SkillFileManager.java +++ b/app/src/main/java/cn/lineai/data/service/SkillFileManager.java @@ -374,6 +374,10 @@ public String readUtf8(File file, int maxChars) { } public void writeUtf8(File file, String content) { + writeBytes(file, safe(content).getBytes(StandardCharsets.UTF_8)); + } + + public void writeBytes(File file, byte[] content) { try { File parent = file.getParentFile(); if (parent != null && !parent.exists()) { @@ -381,7 +385,7 @@ public void writeUtf8(File file, String content) { } FileOutputStream output = new FileOutputStream(file, false); try { - output.write(safe(content).getBytes(StandardCharsets.UTF_8)); + output.write(content); } finally { output.close(); } @@ -476,43 +480,74 @@ public void copyUriToFile(String uri, File target) throws Exception { } } + private static final int MAX_ZIP_FILES = 512; + private static final long MAX_ZIP_ENTRY_BYTES = 8L * 1024 * 1024; + private static final long MAX_ZIP_TOTAL_BYTES = 40L * 1024 * 1024; + public void unzip(File source, File target) throws Exception { target.mkdirs(); File canonicalTarget = target.getCanonicalFile(); + int fileCount = 0; + long totalBytes = 0; + boolean hasSkillMd = false; ZipInputStream input = new ZipInputStream(new BufferedInputStream(new FileInputStream(source))); try { ZipEntry entry; while ((entry = input.getNextEntry()) != null) { - File out = new File(target, entry.getName()).getCanonicalFile(); + String entryName = entry.getName() == null ? "" : entry.getName().replace('\\', '/'); + if (entryName.length() == 0 || entryName.startsWith("/") || entryName.indexOf('\u0000') >= 0) { + throw new IllegalArgumentException("ZIP 包含无效条目。"); + } + File out = new File(target, entryName).getCanonicalFile(); if (!out.getPath().equals(canonicalTarget.getPath()) && !out.getPath().startsWith(canonicalTarget.getPath() + File.separator)) { throw new IllegalArgumentException(resourceProvider != null - ? resourceProvider.getString(R.string.skill_zip_entry_out_of_bounds, entry.getName()) - : "ZIP 条目越界: " + entry.getName()); + ? resourceProvider.getString(R.string.skill_zip_entry_out_of_bounds, entryName) + : "ZIP 条目越界: " + entryName); } if (entry.isDirectory()) { out.mkdirs(); } else { + fileCount++; + if (fileCount > MAX_ZIP_FILES) { + throw new IllegalArgumentException("ZIP 文件数量超过限制。"); + } File parent = out.getParentFile(); if (parent != null && !parent.exists()) { parent.mkdirs(); } + long entryBytes = 0; BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(out, false)); try { byte[] buffer = new byte[8192]; int read; while ((read = input.read(buffer)) != -1) { + entryBytes += read; + totalBytes += read; + if (entryBytes > MAX_ZIP_ENTRY_BYTES || totalBytes > MAX_ZIP_TOTAL_BYTES) { + throw new IllegalArgumentException("ZIP 解压后大小超过限制。"); + } output.write(buffer, 0, read); } } finally { output.close(); } + if ("skill.md".equalsIgnoreCase(out.getName())) { + hasSkillMd = true; + } } input.closeEntry(); } + } catch (Exception e) { + deleteRecursive(target); + throw e; } finally { input.close(); } + if (fileCount == 0 || !hasSkillMd) { + deleteRecursive(target); + throw new IllegalArgumentException(fileCount == 0 ? "ZIP 技能包为空。" : "ZIP 技能包缺少 SKILL.md。"); + } } public void deleteRecursive(File file) { diff --git a/app/src/main/java/cn/lineai/data/service/SkillHubClient.java b/app/src/main/java/cn/lineai/data/service/SkillHubClient.java new file mode 100644 index 00000000..03901238 --- /dev/null +++ b/app/src/main/java/cn/lineai/data/service/SkillHubClient.java @@ -0,0 +1,496 @@ +package cn.lineai.data.service; + +import cn.lineai.R; +import cn.lineai.model.SkillHubModels; +import cn.lineai.resource.ResourceProvider; +import cn.lineai.security.SimpleHttpClient; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONObject; + +public final class SkillHubClient { + private static final String API_ROOT = "https://api.skillhub.cn"; + private static final int CONNECT_TIMEOUT_MS = 15000; + private static final int READ_TIMEOUT_MS = 30000; + private static final int MAX_JSON_CHARS = 2 * 1024 * 1024; + private static final int MAX_MARKDOWN_CHARS = 512 * 1024; + private static final int MAX_ZIP_BYTES = 20 * 1024 * 1024; + private static final int MAX_ICON_BYTES = 512 * 1024; + + private final ResourceProvider resourceProvider; + + public SkillHubClient(ResourceProvider resourceProvider) { + this.resourceProvider = resourceProvider; + } + + public SkillHubModels.Page list(int page, int pageSize, String keyword, String category, + String source, String sortBy, String order) throws Exception { + int safePage = Math.max(1, page); + int safePageSize = Math.max(1, Math.min(50, pageSize)); + StringBuilder url = new StringBuilder(API_ROOT + "/api/skills?page=") + .append(safePage).append("&pageSize=").append(safePageSize); + append(url, "keyword", keyword); + append(url, "category", category); + if (!"all".equals(source)) { + append(url, "source", source); + } + append(url, "sortBy", sortBy); + append(url, "order", order); + JSONObject root = getJson(url.toString()); + if (root.optInt("code", -1) != 0) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_response) + + " " + root.optString("message")); + } + JSONObject data = root.optJSONObject("data"); + if (data == null) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_missing_data)); + } + JSONArray values = data.optJSONArray("skills"); + ArrayList skills = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value != null) { + skills.add(parseSummary(value)); + } + } + } + return new SkillHubModels.Page(skills, data.optLong("total")); + } + + public SkillHubModels.Detail detail(String rawSlug) throws Exception { + String slug = requireSlug(rawSlug); + JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug)); + JSONObject skill = root.optJSONObject("skill"); + if (skill == null) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_missing_skill)); + } + JSONObject latestVersion = root.optJSONObject("latestVersion"); + JSONObject namespace = root.optJSONObject("namespace"); + JSONObject owner = root.optJSONObject("owner"); + JSONObject publisher = root.optJSONObject("publisher"); + JSONObject stats = skill.optJSONObject("stats"); + JSONObject labels = skill.optJSONObject("labels"); + String description = prefer(skill.optString("summary_zh"), skill.optString("summary")); + String version = latestVersion == null ? "" : latestVersion.optString("version"); + SkillHubModels.Summary summary = new SkillHubModels.Summary( + slug, + skill.optString("displayName", slug), + description, + owner == null ? "" : prefer(owner.optString("displayName"), owner.optString("handle")), + skill.optString("category"), + skill.optString("source"), + version, + skill.optString("iconUrl"), + stats == null ? 0 : stats.optLong("downloads"), + stats == null ? 0 : stats.optLong("stars"), + skill.optLong("updatedAt"), + skill.optBoolean("verified") || skill.optBoolean("isAuthorVerified"), + labels != null && "true".equalsIgnoreCase(labels.optString("requires_api_key")), + subCategories(skill.optJSONArray("subCategories")) + ); + String namespaceHandle = namespace == null ? "" : namespace.optString("handle"); + JSONObject security = preferredSecurityReport(root.optJSONObject("securityReports")); + List skillFiles = files(slug, namespaceHandle); + return new SkillHubModels.Detail( + summary, + namespace == null ? "" : namespace.optString("canonicalName"), + publisher == null ? "" : publisher.optString("name"), + security == null ? "" : security.optString("status"), + security == null ? "" : security.optString("statusText"), + markdown(slug, version, namespaceHandle), + strings(skill.optJSONArray("tags")), + skillFiles, + comments(slug, namespaceHandle), + versions(slug, namespaceHandle), + evaluation(slug, namespaceHandle), + testCases(slug, namespaceHandle) + ); + } + + public String fileContent(String rawSlug, String rawVersion, String rawPath) throws Exception { + String slug = requireSlug(rawSlug); + String version = requireVersion(rawVersion); + String path = requireFilePath(rawPath); + String value = SimpleHttpClient.get( + API_ROOT + "/api/v1/skills/" + encode(slug) + "/file?version=" + + encode(version) + "&path=" + encode(path), + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS); + if (value.length() > MAX_MARKDOWN_CHARS) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_file_too_large)); + } + return value; + } + + public byte[] download(String rawSlug, String rawVersion) throws Exception { + String slug = requireSlug(rawSlug); + String version = requireVersion(rawVersion); + SimpleHttpClient.DownloadResult result = SimpleHttpClient.download( + API_ROOT + "/api/v1/download?slug=" + encode(slug) + "&version=" + encode(version), + CONNECT_TIMEOUT_MS, + 60000, + MAX_ZIP_BYTES + ); + byte[] bytes = result.bytes; + if (bytes.length < 4 || bytes[0] != 'P' || bytes[1] != 'K') { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_zip)); + } + return bytes; + } + + public byte[] icon(String rawUrl) throws Exception { + String url = requireIconUrl(rawUrl); + SimpleHttpClient.DownloadResult result = SimpleHttpClient.download( + url, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS, MAX_ICON_BYTES); + if (!result.mimeType.toLowerCase().startsWith("image/")) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_not_image)); + } + return result.bytes; + } + + String requireIconUrl(String rawUrl) { + String value = rawUrl == null ? "" : rawUrl.trim(); + try { + URI uri = new URI(value); + String host = uri.getHost(); + if (!"https".equalsIgnoreCase(uri.getScheme()) + || host == null + || !("skillhub.cn".equalsIgnoreCase(host) + || "www.skillhub.cn".equalsIgnoreCase(host) + || "api.skillhub.cn".equalsIgnoreCase(host) + || "cloudcache.tencent-cloud.com".equalsIgnoreCase(host) + || "skillhub-1388575217.cos.accelerate.myqcloud.com".equalsIgnoreCase(host))) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_icon_url)); + } + return uri.toASCIIString(); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException( + resourceProvider.getString(R.string.skillhub_error_invalid_icon_url), e); + } + } + + private List files(String slug, String namespace) throws Exception { + JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug) + "/files" + + namespaceQuery(namespace)); + JSONArray values = root.optJSONArray("files"); + ArrayList files = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value != null) { + files.add(new SkillHubModels.FileEntry( + value.optString("path"), value.optString("sha256"), value.optLong("size"))); + } + } + } + return files; + } + + private String markdown(String slug, String version, String namespace) throws Exception { + if (version.length() == 0) { + return ""; + } + String url = API_ROOT + "/api/v1/skills/" + encode(slug) + "/file?version=" + + encode(version) + "&path=" + encode("SKILL.md"); + if (namespace.length() > 0) { + url += "&namespace=" + encode(namespace); + } + String value = SimpleHttpClient.get(url, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS); + if (value.length() > MAX_MARKDOWN_CHARS) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_doc_too_large)); + } + return value; + } + + private List comments(String slug, String namespace) throws Exception { + JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug) + "/comments" + + namespaceQuery(namespace)); + JSONArray values = root.optJSONArray("items"); + ArrayList result = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value != null) { + result.add(parseComment(value)); + } + } + } + return result; + } + + public List commentReplies( + String rawSlug, long commentId, String namespace) throws Exception { + String slug = requireSlug(rawSlug); + if (commentId <= 0) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_comment_id)); + } + JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug) + + "/comments/" + commentId + "/replies" + namespaceQuery(namespace)); + JSONArray values = root.optJSONArray("items"); + ArrayList result = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value != null) { + result.add(parseComment(value)); + } + } + } + return result; + } + + static SkillHubModels.Comment parseComment(JSONObject value) { + JSONObject user = value.optJSONObject("user"); + JSONObject replies = value.optJSONObject("replies"); + JSONArray preview = replies == null ? null : replies.optJSONArray("preview"); + ArrayList children = new ArrayList<>(); + if (preview != null) { + for (int i = 0; i < preview.length(); i++) { + JSONObject child = preview.optJSONObject(i); + if (child != null) { + children.add(parseComment(child)); + } + } + } + String author = prefer( + user == null ? "" : user.optString("displayName"), + value.optString("authorName")); + return new SkillHubModels.Comment( + value.optLong("id"), + user == null ? value.optLong("userId") : user.optLong("id", value.optLong("userId")), + value.optLong("parentId"), + author, + user == null ? "" : user.optString("handle"), + user == null ? value.optString("authorAvatar") : user.optString("avatarUrl"), + value.optString("content"), + value.optLong("createdAt"), + value.optLong("likeCount"), + replies == null ? value.optLong("replyCount") : replies.optLong("total"), + value.optBoolean("liked"), + value.optString("status"), + strings(value.optJSONArray("imageUrls")), + children); + } + + private List versions(String slug, String namespace) throws Exception { + JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug) + "/versions" + + namespaceQuery(namespace)); + JSONArray values = root.optJSONArray("versions"); + ArrayList result = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value == null) { + continue; + } + JSONObject security = preferredSecurityReport(value.optJSONObject("securityReports")); + result.add(new SkillHubModels.Version( + value.optString("version"), value.optString("changelog"), value.optLong("createdAt"), + security == null ? "" : security.optString("status"), + security == null ? "" : security.optString("statusText"))); + } + } + return result; + } + + private SkillHubModels.Evaluation evaluation(String slug, String namespace) throws Exception { + JSONObject value = getJson(API_ROOT + "/api/v1/skills/" + encode(slug) + "/evaluation" + + namespaceQuery(namespace)); + JSONObject dimensions = value.optJSONObject("dimensions"); + ArrayList highlights = new ArrayList<>(); + ArrayList suggestions = new ArrayList<>(); + double score = 0; + int scoreCount = 0; + if (dimensions != null) { + for (String key : new String[] {"effectiveness", "reliability", "adaptability", "convention", "trust"}) { + JSONObject dimension = dimensions.optJSONObject(key); + if (dimension == null) { + continue; + } + double valueScore = dimension.optDouble("score", 0); + if (valueScore > 0) { + score += valueScore; + scoreCount++; + } + String userReason = dimension.optString("userReason").trim(); + if (userReason.length() > 0) { + highlights.add(userReason); + } + String suggestion = dimension.optString("suggestion").trim(); + if (suggestion.length() > 0) { + suggestions.add(suggestion); + } + } + } + return new SkillHubModels.Evaluation( + value.length() == 0 ? "" : "completed", + scoreCount == 0 ? 0 : score / scoreCount, + prefer(value.optString("userSummary"), value.optString("summary")), + highlights, suggestions); + } + + private List testCases(String slug, String namespace) throws Exception { + JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug) + "/testcases" + + namespaceQuery(namespace)); + JSONArray values = root.optJSONArray("testcases"); + ArrayList result = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value != null) { + result.add(new SkillHubModels.TestCase( + resourceProvider.getString(R.string.skillhub_testcase_label, i + 1), + value.optString("question"), value.optString("answer"))); + } + } + } + return result; + } + + private String namespaceQuery(String namespace) { + return namespace == null || namespace.trim().length() == 0 + ? "" : "?namespace=" + encode(namespace.trim()); + } + + private JSONObject getJson(String url) throws Exception { + String body = SimpleHttpClient.get(url, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS); + if (body.length() > MAX_JSON_CHARS) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_response_too_large)); + } + try { + return new JSONObject(body); + } catch (Exception e) { + throw new IllegalArgumentException( + resourceProvider.getString(R.string.skillhub_error_invalid_json), e); + } + } + + SkillHubModels.Summary parseSummary(JSONObject value) { + String slug = value.optString("slug").trim(); + if (!isSafeSlug(slug)) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_slug)); + } + JSONObject namespace = value.optJSONObject("namespace"); + JSONObject labels = value.optJSONObject("labels"); + return new SkillHubModels.Summary( + slug, + value.optString("name", slug), + prefer(value.optString("description_zh"), value.optString("description")), + prefer(value.optString("ownerName"), namespace == null ? "" : namespace.optString("displayName")), + value.optString("category"), + value.optString("source"), + value.optString("version"), + value.optString("iconUrl"), + value.optLong("downloads"), + value.optLong("stars"), + value.optLong("updated_at"), + value.optBoolean("verified"), + labels != null && "true".equalsIgnoreCase(labels.optString("requires_api_key")), + subCategories(value.optJSONArray("subCategories")) + ); + } + + private static List strings(JSONArray values) { + ArrayList result = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + String value = values.optString(i).trim(); + if (value.length() > 0) { + result.add(value); + } + } + } + return result; + } + + private static List subCategories(JSONArray values) { + ArrayList result = new ArrayList<>(); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + JSONObject value = values.optJSONObject(i); + if (value != null && value.optString("name").trim().length() > 0) { + result.add(value.optString("name").trim()); + } + } + } + return result; + } + + private static JSONObject preferredSecurityReport(JSONObject reports) { + if (reports == null) { + return null; + } + JSONObject suspicious = null; + for (String name : new String[] {"keen", "sanbu"}) { + JSONObject report = reports.optJSONObject(name); + if (report == null) { + continue; + } + if (!"benign".equalsIgnoreCase(report.optString("status"))) { + return report; + } + suspicious = report; + } + return suspicious; + } + + private static void append(StringBuilder url, String key, String value) { + if (value != null && value.trim().length() > 0) { + url.append('&').append(key).append('=').append(encode(value.trim())); + } + } + + String requireSlug(String value) { + String slug = value == null ? "" : value.trim(); + if (!isSafeSlug(slug)) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_unsafe_slug)); + } + return slug; + } + + private static boolean isSafeSlug(String value) { + return value != null && value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + } + + String requireFilePath(String value) { + String path = value == null ? "" : value.trim(); + if (path.length() == 0 || path.length() > 512 || path.startsWith("/") + || path.indexOf('\\') >= 0 || path.indexOf('\u0000') >= 0) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_path)); + } + for (String segment : path.split("/", -1)) { + if (segment.length() == 0 || ".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_path)); + } + } + return path; + } + + private String requireVersion(String value) { + String version = value == null ? "" : value.trim(); + if (!version.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}")) { + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_version)); + } + return version; + } + + private static String prefer(String first, String second) { + return first != null && first.trim().length() > 0 ? first.trim() + : second == null ? "" : second.trim(); + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java b/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java new file mode 100644 index 00000000..12c387e4 --- /dev/null +++ b/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java @@ -0,0 +1,521 @@ +package cn.lineai.data.service; + +import android.os.Build; +import android.webkit.CookieManager; +import cn.lineai.R; +import cn.lineai.model.SkillHubModels; +import cn.lineai.model.SkillRecord; +import cn.lineai.resource.ResourceProvider; +import cn.lineai.security.SimpleHttpClient; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import org.json.JSONObject; + +public final class SkillHubSessionClient { + private static final String API_ROOT = "https://api.skillhub.cn"; + private static final String SITE_ROOT = "https://skillhub.cn"; + private static final int CONNECT_TIMEOUT_MS = 15000; + private static final int READ_TIMEOUT_MS = 30000; + private static final int MAX_RESPONSE_CHARS = 256 * 1024; + private static final int MAX_COOKIE_CHARS = 16 * 1024; + private static final int MAX_REQUEST_BODY_CHARS = 16 * 1024; + private static final int MAX_PUBLISH_FILES = 200; + private static final int MAX_PUBLISH_FILE_BYTES = 2 * 1024 * 1024; + private static final int MAX_PUBLISH_TOTAL_BYTES = 10 * 1024 * 1024; + private static final int MAX_DISPLAY_NAME_CHARS = 100; + private static final int MAX_COMMENT_CHARS = 500; + + private final ResourceProvider resources; + + public SkillHubSessionClient(ResourceProvider resources) { + this.resources = resources; + } + + public void publish( + SkillRecord skill, String rawSlug, String rawDisplayName, String rawVersion) throws Exception { + if (skill == null || SkillRecord.LOCATION_SSH.equals(skill.getLocation())) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_select_local_skill)); + } + String slug = requireSlug(rawSlug); + String displayName = requireText(rawDisplayName); + String version = requireVersion(rawVersion); + List files = collectPublishFiles(skill); + + JSONObject payload = new JSONObject(); + payload.put("slug", slug); + payload.put("displayName", displayName); + payload.put("version", version); + payload.put("summaryZh", skill.getDescription()); + payload.put("iconUrl", ""); + + String boundary = "LineCode-" + UUID.randomUUID().toString(); + byte[] body = multipart(boundary, payload.toString(), files); + SimpleHttpClient.Request request = new SimpleHttpClient.Request( + API_ROOT + "/api/v1/community/skills/publish", "POST", null); + request.bodyBytes = body; + request.connectTimeoutMs = CONNECT_TIMEOUT_MS; + request.readTimeoutMs = 60000; + request.headers.put("Accept", "application/json"); + request.headers.put("Content-Type", "multipart/form-data; boundary=" + boundary); + String cookie = sessionCookie(); + if (cookie.length() > 0) { + request.headers.put("Cookie", cookie); + } + SimpleHttpClient.Response response = SimpleHttpClient.execute(request); + requireAuthenticatedSuccess(response, publishError(response)); + } + + public Session currentSession() throws Exception { + SimpleHttpClient.Response response = request("GET", "/api/v1/auth/me"); + if (response.code == 401) { + return Session.signedOut(); + } + requireSuccess(response, resources.getString(R.string.skillhub_error_get_account_failed)); + return Session.signedIn(parseAccount(new JSONObject(response.body))); + } + + public SkillHubModels.Comment postComment( + String rawSlug, String namespace, String rawContent) throws Exception { + String slug = requireSlug(rawSlug); + String content = requireCommentContent(rawContent); + JSONObject body = new JSONObject(); + body.put("content", content); + body.put("imageUrls", new org.json.JSONArray()); + SimpleHttpClient.Response response = jsonRequest( + "POST", + "/api/v1/skills/" + encode(slug) + "/comments" + namespaceQuery(namespace), + body.toString()); + requireAuthenticatedSuccess(response, resources.getString(R.string.skillhub_error_post_comment_failed)); + return SkillHubClient.parseComment(new JSONObject(response.body)); + } + + public SkillHubModels.Comment postCommentReply( + String rawSlug, long commentId, String namespace, String rawContent) throws Exception { + String slug = requireSlug(rawSlug); + requireCommentId(commentId); + String content = requireCommentContent(rawContent); + JSONObject body = new JSONObject(); + body.put("content", content); + body.put("imageUrls", new org.json.JSONArray()); + SimpleHttpClient.Response response = jsonRequest( + "POST", + "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + + "/replies" + namespaceQuery(namespace), + body.toString()); + requireAuthenticatedSuccess(response, resources.getString(R.string.skillhub_error_reply_comment_failed)); + return SkillHubClient.parseComment(new JSONObject(response.body)); + } + + public void setCommentLiked( + String rawSlug, long commentId, String namespace, boolean liked) throws Exception { + String slug = requireSlug(rawSlug); + requireCommentId(commentId); + SimpleHttpClient.Response response = request( + liked ? "POST" : "DELETE", + "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + + "/like" + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, liked + ? resources.getString(R.string.skillhub_error_like_comment_failed) + : resources.getString(R.string.skillhub_error_unlike_comment_failed)); + } + + public void deleteComment(String rawSlug, long commentId, String namespace) throws Exception { + String slug = requireSlug(rawSlug); + requireCommentId(commentId); + SimpleHttpClient.Response response = request( + "DELETE", "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, resources.getString(R.string.skillhub_error_delete_comment_failed)); + } + + public boolean starred(String rawSlug, String namespace) throws Exception { + String slug = requireSlug(rawSlug); + SimpleHttpClient.Response response = request( + "GET", "/api/v1/skills/" + encode(slug) + "/starred" + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, resources.getString(R.string.skillhub_error_get_star_status_failed)); + return new JSONObject(response.body).optBoolean("starred"); + } + + public void setStarred(String rawSlug, String namespace, boolean starred) throws Exception { + String slug = requireSlug(rawSlug); + SimpleHttpClient.Response response = request( + starred ? "POST" : "DELETE", + "/api/v1/skills/" + encode(slug) + "/star" + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, starred + ? resources.getString(R.string.skillhub_error_star_failed) + : resources.getString(R.string.skillhub_error_unstar_failed)); + } + + private SimpleHttpClient.Response jsonRequest(String method, String path, String body) throws Exception { + if (body == null || body.length() > MAX_REQUEST_BODY_CHARS) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_request_too_large)); + } + return execute(method, path, body, "application/json"); + } + + public void logout() throws Exception { + SimpleHttpClient.Response response = request("POST", "/api/v1/auth/logout"); + if (response.code != 401) { + requireSuccess(response, resources.getString(R.string.skillhub_error_logout_failed)); + } + clearSkillHubCookies(); + } + + private SimpleHttpClient.Response request(String method, String path) throws Exception { + return execute(method, path, null, null); + } + + private SimpleHttpClient.Response execute( + String method, String path, String body, String contentType) throws Exception { + SimpleHttpClient.Request request = new SimpleHttpClient.Request(API_ROOT + path, method, body); + request.connectTimeoutMs = CONNECT_TIMEOUT_MS; + request.readTimeoutMs = READ_TIMEOUT_MS; + request.headers.put("Accept", "application/json"); + if (contentType != null) { + request.headers.put("Content-Type", contentType); + } + String cookie = sessionCookie(); + if (cookie.length() > 0) { + request.headers.put("Cookie", cookie); + } + SimpleHttpClient.Response response = SimpleHttpClient.execute(request); + if (response.body.length() > MAX_RESPONSE_CHARS) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_account_response_too_large)); + } + return response; + } + + private String sessionCookie() { + String cookie = CookieManager.getInstance().getCookie(API_ROOT); + return requireSafeCookie(cookie); + } + + String requireSafeCookie(String rawCookie) { + String cookie = rawCookie == null ? "" : rawCookie.trim(); + if (cookie.length() > MAX_COOKIE_CHARS + || cookie.indexOf('\r') >= 0 + || cookie.indexOf('\n') >= 0) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_invalid_session)); + } + return cookie; + } + + Account parseAccount(JSONObject root) { + JSONObject user = root.optJSONObject("user"); + if (user == null) { + user = root; + } + String handle = first( + user.optString("handle"), + user.optString("username"), + user.optString("userName")); + String displayName = first( + user.optString("displayName"), + user.optString("nickname"), + user.optString("name"), + handle); + if (displayName.length() == 0 && handle.length() == 0) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_incomplete_account)); + } + return new Account( + displayName, + handle, + first(user.optString("avatarUrl"), user.optString("avatar"), user.optString("image"))); + } + + private void clearSkillHubCookies() { + CookieManager manager = CookieManager.getInstance(); + LinkedHashMap names = new LinkedHashMap<>(); + collectCookieNames(names, manager.getCookie(API_ROOT)); + collectCookieNames(names, manager.getCookie(SITE_ROOT)); + for (String name : names.keySet()) { + String expired = name + "=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Secure"; + manager.setCookie(API_ROOT, expired); + manager.setCookie(SITE_ROOT, expired + "; Domain=skillhub.cn"); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + manager.flush(); + } + } + + private void collectCookieNames(Map names, String rawCookie) { + String cookie = requireSafeCookie(rawCookie); + if (cookie.length() == 0) { + return; + } + for (String part : cookie.split(";")) { + int equals = part.indexOf('='); + String name = equals < 0 ? part.trim() : part.substring(0, equals).trim(); + if (name.matches("[A-Za-z0-9_.-]{1,128}")) { + names.put(name, Boolean.TRUE); + } + } + } + + private void requireAuthenticatedSuccess( + SimpleHttpClient.Response response, String message) throws Exception { + if (response.code == 401) { + throw new IllegalStateException(resources.getString(R.string.skillhub_error_not_logged_in)); + } + requireSuccess(response, message); + } + + private String requireCommentContent(String rawContent) { + String content = rawContent == null ? "" : rawContent.trim(); + if (content.length() == 0 || content.codePointCount(0, content.length()) > MAX_COMMENT_CHARS) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_comment_length)); + } + return content; + } + + private void requireCommentId(long commentId) { + if (commentId <= 0) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_invalid_comment_id)); + } + } + + private String publishError(SimpleHttpClient.Response response) { + String fallback = resources.getString(R.string.skillhub_error_publish_failed); + if (response == null || response.body.length() == 0) { + return fallback; + } + try { + JSONObject value = new JSONObject(response.body); + String message = first(value.optString("message"), value.optString("error")); + return message.length() == 0 ? fallback : message; + } catch (Exception ignored) { + return fallback; + } + } + + private String requireText(String rawValue) { + String value = rawValue == null ? "" : rawValue.trim(); + if (value.length() == 0 || value.codePointCount(0, value.length()) > MAX_DISPLAY_NAME_CHARS) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_name_too_long)); + } + return value; + } + + private String requireVersion(String rawVersion) { + String version = rawVersion == null ? "" : rawVersion.trim(); + if (!version.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}")) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_invalid_skill_version)); + } + return version; + } + + // Slug 校验与 SkillHubClient.requireSlug 保持一致;SkillHubSessionClient 持有自己的 + // ResourceProvider,因此这里直接读取资源字符串,不再依赖 SkillHubClient 的静态方法。 + private String requireSlug(String rawSlug) { + String slug = rawSlug == null ? "" : rawSlug.trim(); + if (!slug.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}")) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_unsafe_slug)); + } + return slug; + } + + List collectPublishFiles(SkillRecord skill) throws Exception { + File root = new File(skill.getRootPath()).getCanonicalFile(); + if (!root.isDirectory()) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_dir_not_exist)); + } + ArrayList files = new ArrayList<>(); + collectPublishFiles(root, root, files, new long[] {0}); + files.sort(Comparator.comparing(file -> file.path)); + boolean hasSkillMarkdown = false; + for (PublishFile file : files) { + if ("SKILL.md".equals(file.path)) { + hasSkillMarkdown = true; + break; + } + } + if (!hasSkillMarkdown) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_missing_skill_md)); + } + return files; + } + + private void collectPublishFiles( + File root, File current, List files, long[] total) throws Exception { + File[] children = current.listFiles(); + if (children == null) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_cannot_read_dir)); + } + for (File child : children) { + File canonical = child.getCanonicalFile(); + String rootPath = root.getPath(); + if (!canonical.getPath().startsWith(rootPath + File.separator)) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_path_out_of_bounds)); + } + String relative = canonical.getPath().substring(rootPath.length() + 1) + .replace(File.separatorChar, '/'); + if (canonical.isDirectory()) { + collectPublishFiles(root, canonical, files, total); + continue; + } + if (!canonical.isFile() || isSensitive(relative)) { + if (isSensitive(relative)) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_sensitive_file) + relative); + } + continue; + } + long length = canonical.length(); + if (length > MAX_PUBLISH_FILE_BYTES) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_file_too_large_with_name) + relative); + } + total[0] += length; + if (total[0] > MAX_PUBLISH_TOTAL_BYTES) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_total_size_exceeded)); + } + if (files.size() >= MAX_PUBLISH_FILES) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_file_count_exceeded)); + } + files.add(new PublishFile(relative, readFile(canonical, (int) length))); + } + } + + private static boolean isSensitive(String path) { + String name = path.toLowerCase(Locale.ROOT); + String leaf = name.substring(name.lastIndexOf('/') + 1); + return ".env".equals(leaf) || leaf.startsWith(".env.") + || leaf.contains("credential") || leaf.contains("secret") + || leaf.endsWith(".pem") || leaf.endsWith(".key") + || leaf.endsWith(".p12") || leaf.endsWith(".pfx") + || leaf.endsWith(".jks") || leaf.endsWith(".keystore") + || "id_rsa".equals(leaf) || "id_ed25519".equals(leaf); + } + + private static byte[] readFile(File file, int expectedLength) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(expectedLength); + FileInputStream input = new FileInputStream(file); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } finally { + input.close(); + } + return output.toByteArray(); + } + + private byte[] multipart( + String boundary, String payload, List files) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + writePart(output, boundary, "payload", null, "application/json", + payload.getBytes(StandardCharsets.UTF_8)); + for (PublishFile file : files) { + writePart(output, boundary, "files", file.path, + "application/octet-stream", file.bytes); + } + output.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); + if (output.size() > MAX_PUBLISH_TOTAL_BYTES + 512 * 1024) { + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_publish_request_too_large)); + } + return output.toByteArray(); + } + + private static void writePart( + ByteArrayOutputStream output, String boundary, String name, + String filename, String contentType, byte[] bytes) throws Exception { + output.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + String disposition = "Content-Disposition: form-data; name=\"" + name + "\""; + if (filename != null) { + disposition += "; filename=\"" + filename.replace("\"", "") + "\""; + } + output.write((disposition + "\r\nContent-Type: " + contentType + "\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + output.write(bytes); + output.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + static final class PublishFile { + final String path; + final byte[] bytes; + + PublishFile(String path, byte[] bytes) { + this.path = path; + this.bytes = bytes; + } + } + + private String namespaceQuery(String namespace) { + String value = namespace == null ? "" : namespace.trim(); + return value.length() == 0 ? "" : "?namespace=" + encode(value); + } + + private String encode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (Exception e) { + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_cannot_encode), e); + } + } + + private void requireSuccess(SimpleHttpClient.Response response, String message) throws Exception { + if (response.code < 200 || response.code >= 300) { + throw new Exception(message + resources.getString(R.string.skillhub_error_http_status, response.code)); + } + } + + private static String first(String... values) { + for (String value : values) { + if (value != null && value.trim().length() > 0) { + return value.trim(); + } + } + return ""; + } + + public static final class Account { + private final String displayName; + private final String handle; + private final String avatarUrl; + + Account(String displayName, String handle, String avatarUrl) { + this.displayName = displayName == null ? "" : displayName; + this.handle = handle == null ? "" : handle; + this.avatarUrl = avatarUrl == null ? "" : avatarUrl; + } + + public String getDisplayName() { return displayName; } + public String getHandle() { return handle; } + public String getAvatarUrl() { return avatarUrl; } + } + + public static final class Session { + private final boolean authenticated; + private final Account account; + + private Session(boolean authenticated, Account account) { + this.authenticated = authenticated; + this.account = account; + } + + public static Session signedIn(Account account) { return new Session(true, account); } + public static Session signedOut() { return new Session(false, null); } + public boolean isAuthenticated() { return authenticated; } + public Account getAccount() { return account; } + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/lineai/mvp/ChatSessionStore.java b/app/src/main/java/cn/lineai/mvp/ChatSessionStore.java index bc79f5ef..30f4a882 100644 --- a/app/src/main/java/cn/lineai/mvp/ChatSessionStore.java +++ b/app/src/main/java/cn/lineai/mvp/ChatSessionStore.java @@ -13,6 +13,8 @@ public final class ChatSessionStore { private int messageSequence = 1; private int generationSequence = 1; private boolean streaming; + private long processingStartedAt; + private long processingFinishedAt; public ArrayList mutableMessages() { return messages; @@ -101,9 +103,30 @@ public boolean isStreaming() { } public void setStreaming(boolean streaming) { + if (streaming && !this.streaming) { + processingStartedAt = System.currentTimeMillis(); + processingFinishedAt = 0; + } else if (!streaming) { + finishProcessing(System.currentTimeMillis()); + } this.streaming = streaming; } + public ChatMessage withProcessingTimes(ChatMessage message) { + return message.withProcessingTimes(processingStartedAt, processingFinishedAt); + } + + void finishProcessing(long now) { + if (processingStartedAt <= 0 || processingFinishedAt > 0) return; + processingFinishedAt = Math.max(processingStartedAt, now); + for (int i = 0; i < messages.size(); i++) { + ChatMessage message = messages.get(i); + if (message.getProcessingStartedAt() == processingStartedAt && message.getProcessingFinishedAt() == 0) { + messages.set(i, message.withProcessingTimes(processingStartedAt, processingFinishedAt)); + } + } + } + private void resetMessageSequence() { int max = 0; for (ChatMessage message : messages) { diff --git a/app/src/main/java/cn/lineai/mvp/ContextCompactionController.java b/app/src/main/java/cn/lineai/mvp/ContextCompactionController.java index 1f0ac1a8..7eb9b163 100644 --- a/app/src/main/java/cn/lineai/mvp/ContextCompactionController.java +++ b/app/src/main/java/cn/lineai/mvp/ContextCompactionController.java @@ -479,12 +479,17 @@ private void finishContextCompaction( HashSet retainedIds = messageIdSet(retainedUserMessages); ArrayList compacted = new ArrayList<>(); for (ChatMessage message : messages) { - if (progressId.equals(message.getId()) || preservedIds.contains(message.getId()) || retainedIds.contains(message.getId())) { + if (progressId.equals(message.getId()) || preservedIds.contains(message.getId())) { continue; } - compacted.add(baseIds.contains(message.getId()) ? message.withExcludeFromContext(true) : message); + if (retainedIds.contains(message.getId())) { + compacted.add(message); + } else if (baseIds.contains(message.getId())) { + compacted.add(message.withExcludeFromContext(true)); + } else { + compacted.add(message); + } } - compacted.addAll(retainedUserMessages); // 摘要必须进入上下文(excludeFromContext=false),否则模型侧会像"上下文被清空"一样丢失历史。 // 注意:不能用 .withResponseInputItemJson(...) 链式构造,它会基于当前 excludeFromContext 副本, // 这里显式传 false 保证摘要一定进上下文。 diff --git a/app/src/main/java/cn/lineai/mvp/ConversationPersistenceController.java b/app/src/main/java/cn/lineai/mvp/ConversationPersistenceController.java index d14db032..2db4dd89 100644 --- a/app/src/main/java/cn/lineai/mvp/ConversationPersistenceController.java +++ b/app/src/main/java/cn/lineai/mvp/ConversationPersistenceController.java @@ -199,13 +199,13 @@ String deriveTitle() { return host.defaultConversationTitle(context); } - String messageRawJson(ChatMessage message) { + static String messageRawJson(ChatMessage message) { if (message == null) { return ""; } try { JSONObject object = new JSONObject(); - if (message.getRole() == ChatMessage.Role.TOOL) { + if (message.getRole() == ChatMessage.Role.TOOL || message.isRetryNotice()) { object.put("diff_id", message.getDiffId()); object.put("review_state", message.getReviewState()); object.put("review_message", message.getReviewMessage()); @@ -226,6 +226,14 @@ String messageRawJson(ChatMessage message) { if (message.getResponseInputItemJson().length() > 0) { object.put("response_input_item_json", message.getResponseInputItemJson()); } + if (message.isModelSwitchNotification()) { + object.put("model_switch_notification", message.getModelSwitchNotification()); + } + if (message.getProcessingStartedAt() > 0) { + object.put("processing_started_at", message.getProcessingStartedAt()); + object.put("processing_finished_at", message.getProcessingFinishedAt()); + object.put("processing_observed_at", System.currentTimeMillis()); + } if (message.hasAttachments()) { JSONArray array = new JSONArray(); for (InputAttachment attachment : message.getAttachments()) { diff --git a/app/src/main/java/cn/lineai/mvp/ExtensionController.java b/app/src/main/java/cn/lineai/mvp/ExtensionController.java index 2e5090dd..5ce87fab 100644 --- a/app/src/main/java/cn/lineai/mvp/ExtensionController.java +++ b/app/src/main/java/cn/lineai/mvp/ExtensionController.java @@ -32,6 +32,8 @@ public interface ExtensionController { void onSkillInstalledFromGitHub(String location, String githubUrl) throws Exception; + SkillRecord onSkillInstalledFromSkillHub(String location, String slug, String version) throws Exception; + void onExtensionEnabledChanged(String kind, String id, boolean enabled); void onExtensionDeleted(String kind, String id); diff --git a/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java b/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java index b8d8ac72..69cf2d31 100644 --- a/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java +++ b/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java @@ -129,6 +129,11 @@ private String errorMessage(Exception e) { return e == null ? null : e.getMessage(); } + SkillRecord installSkillFromSkillHub(String location, String slug, String version) throws Exception { + return extensionRepository.installSkillFromSkillHub( + host.projectPath(), location, slug, version); + } + void deleteExtensions(String kind, List ids) { if (ids == null || ids.isEmpty()) { return; diff --git a/app/src/main/java/cn/lineai/mvp/GenerationFlowController.java b/app/src/main/java/cn/lineai/mvp/GenerationFlowController.java index 2abe2751..a2489f92 100644 --- a/app/src/main/java/cn/lineai/mvp/GenerationFlowController.java +++ b/app/src/main/java/cn/lineai/mvp/GenerationFlowController.java @@ -44,6 +44,8 @@ interface Host { String currentConversationId(); + default String executionPermissionScope() { return ""; } + String syncModePermission(); void persistCurrentConversation(); @@ -69,6 +71,8 @@ default boolean isTerminalProviderExecutionMode() { String toolLimitNotExecutedMessage(); } + cn.lineai.model.ToolApproval pendingToolApproval() { return toolConfirmationController.pendingToolApproval(); } + private static final int MAX_RETRIES = 3; private static final long RETRY_DELAY_MS = 5000L; @@ -86,6 +90,7 @@ default boolean isTerminalProviderExecutionMode() { private final MainThreadDispatcher mainThread; private final BackgroundTaskRunner backgroundTasks; private final Host host; + private final ToolRunController toolRunController; private final ToolConfirmationController toolConfirmationController; private final ToolExecutionScheduler toolExecutionScheduler; private final StreamingRenderController streamingRenderController; @@ -223,6 +228,14 @@ public void executeAcceptedPendingTool(PendingToolExecution pending) { public String currentConversationId() { return host.currentConversationId(); } + + @Override public String executionPermissionScope() { return host.executionPermissionScope(); } + @Override public boolean isPermanentlyAllowed(String scope, ToolCall call) { + return toolRunController.isCommandPermanentlyAllowed(scope, call); + } + @Override public void rememberPermanentAllowance(String scope, ToolCall call) { + toolRunController.allowCommandPermanently(scope, call); + } }; private final ToolExecutionScheduler.Host schedulerHost; @@ -306,6 +319,7 @@ public String currentConversationId() { this.contextCompactionController = contextCompactionController; this.tokenUsageTracker = tokenUsageTracker; this.host = host; + this.toolRunController = toolRunController; this.schedulerHost = () -> host.syncModePermission(); this.toolConfirmationController = new ToolConfirmationController(reviewCallback); this.toolExecutionScheduler = new ToolExecutionScheduler( @@ -325,7 +339,7 @@ public String currentConversationId() { ChatMessage message = messages.get(index); String visibleText = result.parsedToolCalls.hasToolMarkup() ? result.parsedToolCalls.getText() - : message.getContent() + result.textDelta; + : StreamingRenderController.visibleCapturedText(result.rawText); List toolCalls = result.parsedToolCalls.hasToolMarkup() ? mergeToolCalls(message.getToolCalls(), result.parsedToolCalls.getToolCalls()) : message.getToolCalls(); @@ -334,6 +348,9 @@ public String currentConversationId() { message.getReasoningContent() + result.reasoningDelta, true ).withToolCalls(toolCalls, false)); + if (StreamingRenderController.hasProcessingEndMarker(result.rawText)) { + chatSessionStore.finishProcessing(System.currentTimeMillis()); + } host.render(); }, generationId -> chatSessionStore.isActiveGeneration(generationId)); if (this.agentExecutionController != null) { @@ -380,7 +397,7 @@ private void retryableModelStream( ) { String assistantId = host.nextId(); streamingRenderController.initRawText(assistantId); - messages.add(new ChatMessage(assistantId, ChatMessage.Role.ASSISTANT, "", true)); + messages.add(chatSessionStore.withProcessingTimes(new ChatMessage(assistantId, ChatMessage.Role.ASSISTANT, "", true))); host.persistCurrentConversation(); host.render(); @@ -433,7 +450,8 @@ private void handleModelError( } mainThread.post(() -> { - if (cancellationToken != null && cancellationToken.isCancelled()) { + if (!chatSessionStore.isActiveGeneration(generationId) + || cancellationToken != null && cancellationToken.isCancelled()) { return; } int index = findMessageIndex(failedAssistantId); @@ -443,12 +461,13 @@ private void handleModelError( streamingRenderController.removeRawText(failedAssistantId); String retryText = host.formatRetryNotice(nextAttempt + 1, MAX_RETRIES, error.getMessage()); - messages.add(ChatMessage.retryNotice(host.nextId(), retryText)); + messages.add(chatSessionStore.withProcessingTimes(ChatMessage.retryNotice(host.nextId(), retryText))); host.persistCurrentConversation(); host.render(); mainThread.postDelayed(() -> { - if (cancellationToken != null && cancellationToken.isCancelled()) { + if (!chatSessionStore.isActiveGeneration(generationId) + || cancellationToken != null && cancellationToken.isCancelled()) { return; } retryableModelStream(generationId, selectedModel, cancellationToken, @@ -615,7 +634,7 @@ private void finishGeneration( } ToolCallTextParser.Result parsedTextToolCalls = ToolCallTextParser.parse(rawResponseText); List toolCalls = mergeToolCalls(response.getToolCalls(), parsedTextToolCalls.getToolCalls()); - String parsedResponseText = parsedTextToolCalls.hasToolMarkup() ? parsedTextToolCalls.getText() : rawResponseText; + String parsedResponseText = parsedTextToolCalls.hasToolMarkup() ? parsedTextToolCalls.getText() : StreamingRenderController.visibleCapturedText(rawResponseText); String finalText = parsedTextToolCalls.hasToolMarkup() ? parsedResponseText : parsedResponseText.trim().length() == 0 ? message.getContent() : parsedResponseText; @@ -626,6 +645,9 @@ private void finishGeneration( } messages.set(index, message.withContent(finalText, finalReasoning, false) .withToolCalls(toolCalls, false)); + if (StreamingRenderController.hasProcessingEndMarker(rawResponseText)) { + chatSessionStore.finishProcessing(System.currentTimeMillis()); + } if (hasToolCalls) { if (!generationController.canExecuteToolCalls(selectedModel, effectiveUsedToolCalls(usedToolCallCount), toolCalls.size())) { messages.add(new ChatMessage(host.nextId(), ChatMessage.Role.ASSISTANT, @@ -889,9 +911,11 @@ private void failGeneration(int generationId, String assistantId, String text) { int index = findMessageIndex(assistantId); if (index >= 0) { ChatMessage message = messages.get(index); - messages.set(index, message.withContent(displayText, message.getReasoningContent(), false)); + messages.set(index, message.withContent(displayText, message.getReasoningContent(), false) + .withToolReview(message.getDiffId(), message.getReviewState(), message.getReviewMessage(), true, displayText)); } else { - messages.add(new ChatMessage(host.nextId(), ChatMessage.Role.ASSISTANT, displayText, false)); + messages.add(chatSessionStore.withProcessingTimes(new ChatMessage(host.nextId(), ChatMessage.Role.ASSISTANT, displayText, false) + .withToolReview("", "", "", true, displayText))); } streamingRenderController.removeRawText(assistantId); finishActiveGeneration(); diff --git a/app/src/main/java/cn/lineai/mvp/GenerationFlowHost.java b/app/src/main/java/cn/lineai/mvp/GenerationFlowHost.java index f8724485..8368161f 100644 --- a/app/src/main/java/cn/lineai/mvp/GenerationFlowHost.java +++ b/app/src/main/java/cn/lineai/mvp/GenerationFlowHost.java @@ -11,6 +11,8 @@ class GenerationFlowHost implements GenerationFlowController.Host { this.coordinator = coordinator; } + @Override public String executionPermissionScope() { return coordinator.executionPermissionScope(); } + @Override public String nextId() { return coordinator.nextId(); diff --git a/app/src/main/java/cn/lineai/mvp/MainCoordinator.java b/app/src/main/java/cn/lineai/mvp/MainCoordinator.java index 03ae9456..856cb5af 100644 --- a/app/src/main/java/cn/lineai/mvp/MainCoordinator.java +++ b/app/src/main/java/cn/lineai/mvp/MainCoordinator.java @@ -779,6 +779,11 @@ public void onSkillInstalledFromGitHub(String location, String githubUrl) throws extensionManagementController.installSkillFromGitHub(location, githubUrl); } + @Override + public SkillRecord onSkillInstalledFromSkillHub(String location, String slug, String version) throws Exception { + return extensionManagementController.installSkillFromSkillHub(location, slug, version); + } + @Override public void onExtensionEnabledChanged(String kind, String id, boolean enabled) { extensionManagementController.setExtensionEnabled(kind, id, enabled); @@ -958,6 +963,23 @@ boolean isTerminalProviderExecutionMode() { return projectState.isTerminalProviderExecutionMode(toolSettingsRepository); } + String executionPermissionScope() { + org.json.JSONArray target = new org.json.JSONArray(); + target.put(toolSettingsRepository.getExecutionMode()); + if (isTerminalProviderExecutionMode()) { + cn.lineai.ipc.BaseIpcProvider provider = ipcProviderManager.getProviderByType(cn.lineai.ipc.IpcProviderType.TERMINAL); + if (provider == null || !provider.isBound()) return ""; + cn.lineai.ipc.IpcProviderConfig config = provider.getConfig(); + target.put(config.getId()).put(config.getPackageName()).put(config.getServiceClass()); + } else { + // ShellExecuteTool uses SSH for every non-provider execution mode. + SshConfig config = sshService.getConfig(); + if (config == null || config.getHost().isEmpty() || config.getUsername().isEmpty()) return ""; + target.put(config.getHost()).put(config.getPort()).put(config.getUsername()); + } + return target.put(projectState.source()).put(projectState.path()).toString(); + } + boolean isTermuxSshHost() { SshConfig config = sshService.getConfig(); String host = config == null ? "" : config.getHost(); @@ -1025,7 +1047,7 @@ void render() { activeChatMode, chatSessionStore.isStreaming(), messages - )); + ).withToolApproval(generationFlowController == null ? null : generationFlowController.pendingToolApproval())); } void resetTodoState() { diff --git a/app/src/main/java/cn/lineai/mvp/MainDependencies.java b/app/src/main/java/cn/lineai/mvp/MainDependencies.java index 1f252f34..3d00a430 100644 --- a/app/src/main/java/cn/lineai/mvp/MainDependencies.java +++ b/app/src/main/java/cn/lineai/mvp/MainDependencies.java @@ -45,6 +45,7 @@ import cn.lineai.data.repository.StorageStatsRepository; import cn.lineai.data.repository.ThemeSettingsRepository; import cn.lineai.data.repository.ToolSettingsRepository; +import cn.lineai.data.service.ContextResourceProvider; import cn.lineai.resource.ResourceProvider; import cn.lineai.resource.SystemConfigProvider; import cn.lineai.data.repository.ToolSettingsStore; @@ -76,7 +77,6 @@ import cn.lineai.workspace.SafPathResolver; import cn.lineai.workspace.WorkspacePaths; import cn.lineai.workspace.StoragePermissionManager; -import java.io.InputStream; public final class MainDependencies { final Context context; @@ -271,33 +271,6 @@ private ToolCallViewFactoryRegistry createToolCallViewFactoryRegistry() { return registry; } - private static final class ContextResourceProvider implements ResourceProvider { - private final Context context; - - ContextResourceProvider(Context context) { - this.context = context.getApplicationContext(); - } - - @Override - public InputStream openAsset(String path) { - try { - return context.getAssets().open(path); - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - - @Override - public String getString(int resId) { - return context.getString(resId); - } - - @Override - public String getString(int resId, Object... formatArgs) { - return context.getString(resId, formatArgs); - } - } - private static final class ContextSystemConfigProvider implements SystemConfigProvider { private final Context context; diff --git a/app/src/main/java/cn/lineai/mvp/ModelPromptController.java b/app/src/main/java/cn/lineai/mvp/ModelPromptController.java index bb3d8030..6a86b160 100644 --- a/app/src/main/java/cn/lineai/mvp/ModelPromptController.java +++ b/app/src/main/java/cn/lineai/mvp/ModelPromptController.java @@ -24,7 +24,6 @@ import cn.lineai.model.ModelConfig; import cn.lineai.model.ModelContextParser; import cn.lineai.model.ModelStore; -import cn.lineai.tool.BaseTool; import cn.lineai.tool.ToolInfo; import cn.lineai.tool.ToolRegistry; import cn.lineai.workspace.WorkspacePaths; @@ -134,24 +133,35 @@ ArrayList buildModelMessages(String userInput, int usedToolCallCou String promptHomePath = promptHomePath(); String extensionContext = extensionRepository.buildExtensionPrompt(projectPath); String attachmentContext = buildAttachmentPrompt(messages); - String systemContext = joinPromptContext(joinPromptContext(learningContext, attachmentContext), extensionContext); String systemPrompt = systemPromptProvider.build( promptHomePath, aiSettings.getToneMode(), chatModePromptContext(activeChatMode), - systemContext, - buildToolPrompt(selectedModel, usedToolCallCount), + extensionContext, + buildToolPrompt(selectedModel), selectedModel, - renderTodoStateForPrompt() - ); + "" + ) + SystemPromptProvider.runtimeContextRule() + + (aiSettings.isLearningModeEnabled() ? "\nLearning Mode is enabled." + : "\nLearning Mode is disabled. Manual memories may still be supplied as background data."); + String runtimeContext = systemPromptProvider.buildRuntimeContext(learningContext, + renderTodoStateForPrompt(), hasRemainingToolCalls(selectedModel, usedToolCallCount) + ? "" : host.toolsUnavailablePrompt()); modelMessages.add(new SystemModelMessage(systemPrompt)); int contextTokens = ModelContextParser.parse(selectedModel).getContextTokens(); - int reservedTokens = contextManager.estimateTokens(systemPrompt) + 2048; + int reservedTokens = contextManager.estimateTokens(systemPrompt) + + contextManager.estimateTokens(runtimeContext) + + contextManager.estimateTokens(attachmentContext) + 2048; boolean includeReasoning = aiSettings.isPreserveReasoningEnabled(); List contextWindow = contextManager.selectWindow(messages, contextTokens, reservedTokens, includeReasoning); for (ChatMessage message : completeToolCallPairsForRequest(contextWindow, host.interruptedGenerationMessage())) { modelMessages.add(toModelMessage(message, includeReasoning)); + if (message.getRole() == ChatMessage.Role.USER && message.hasAttachments()) { + // Keep paths with their originating user turn (including native image input). + modelMessages.add(new UserModelMessage(buildAttachmentPrompt(java.util.Collections.singletonList(message)))); + } } + modelMessages.add(new UserModelMessage(runtimeContext)); return modelMessages; } @@ -296,11 +306,8 @@ private String modelToolContent(ChatMessage message) { return MessageContentSanitizer.toolContentForModel(message); } - private String buildToolPrompt(ModelConfig selectedModel, int usedToolCallCount) { + private String buildToolPrompt(ModelConfig selectedModel) { host.syncModePermission(); - if (!hasRemainingToolCalls(selectedModel, usedToolCallCount)) { - return host.toolsUnavailablePrompt(); - } toolRegistry.reloadExtensions(); return toolSettingsRepository.buildToolPrompt(new ArrayList(toolRegistry.getAll()), modelProtocolFactory.create(selectedModel.getProtocolType()).supportsNativeTools(selectedModel)); } @@ -342,18 +349,6 @@ private String buildAttachmentPrompt(List history) { + builder.toString().trim(); } - private String joinPromptContext(String first, String second) { - String left = first == null ? "" : first.trim(); - String right = second == null ? "" : second.trim(); - if (left.length() == 0) { - return right; - } - if (right.length() == 0) { - return left; - } - return left + "\n\n" + right; - } - private boolean hasRemainingToolCalls(ModelConfig selectedModel, int usedToolCallCount) { int limit = selectedModel == null ? ModelConfig.DEFAULT_TOOL_CALL_LIMIT : selectedModel.getToolCallLimit(); return limit == ModelConfig.UNLIMITED_TOOL_CALLS || Math.max(0, usedToolCallCount) < limit; diff --git a/app/src/main/java/cn/lineai/mvp/PermissionModeController.java b/app/src/main/java/cn/lineai/mvp/PermissionModeController.java index d06aead3..c1862198 100644 --- a/app/src/main/java/cn/lineai/mvp/PermissionModeController.java +++ b/app/src/main/java/cn/lineai/mvp/PermissionModeController.java @@ -22,6 +22,8 @@ interface PermissionStore { String getPermissionMode(); void setPermissionMode(String mode); + + default void clearPermanentCommandPermissions() {} } interface ChatModeStore { @@ -39,6 +41,8 @@ private static final class ToolSettingsPermissionStore implements PermissionStor this.repository = repository; } + @Override public void clearPermanentCommandPermissions() { repository.clearPermanentCommandPermissions(); } + @Override public String getPermissionMode() { return repository.getPermissionMode(); @@ -109,7 +113,7 @@ public void showPermissionSheet() { options.add(new SheetOption( ToolSettingsRepository.PERMISSION_AUTO, localizedString(R.string.permission_mode_auto, "自动"), - localizedString(R.string.permission_mode_auto_desc, "自动执行已启用工具,危险工具按策略确认"), + localizedString(R.string.permission_mode_auto_desc, "自动执行已启用工具,无需逐次确认"), ToolSettingsRepository.PERMISSION_AUTO.equals(permissionMode) )); options.add(new SheetOption( @@ -132,6 +136,7 @@ public void showPermissionSheet() { : host.storagePermissionMessage(), host.hasExternalStorageAccess() )); + options.add(new SheetOption("commands:revoke", localizedString(R.string.chat_permissions_clear, "撤销已保存的命令许可"), "", false)); host.showPermissionSheet(options); } @@ -149,6 +154,11 @@ public boolean isPermissionModeOption(String id) { } public boolean applyPermissionModeOption(String id) { + if ("commands:revoke".equals(id)) { + permissionStore.clearPermanentCommandPermissions(); + if (context != null) android.widget.Toast.makeText(context, R.string.chat_permissions_cleared, android.widget.Toast.LENGTH_SHORT).show(); + return true; + } if (!isPermissionModeOption(id)) { return false; } diff --git a/app/src/main/java/cn/lineai/mvp/StreamingRenderController.java b/app/src/main/java/cn/lineai/mvp/StreamingRenderController.java index 2d52563e..ab59a806 100644 --- a/app/src/main/java/cn/lineai/mvp/StreamingRenderController.java +++ b/app/src/main/java/cn/lineai/mvp/StreamingRenderController.java @@ -7,6 +7,7 @@ public class StreamingRenderController { private static final long STREAM_RENDER_INTERVAL_MS = 80L; + private static final java.util.regex.Pattern PROCESSING_END_MARKER = java.util.regex.Pattern.compile("", java.util.regex.Pattern.DOTALL); private final MainThreadDispatcher mainThread; private final FlushCallback flushCallback; @@ -107,6 +108,36 @@ public FlushResult getLastFlushResult() { return lastFlushResult; } + public static boolean hasProcessingEndMarker(String raw) { + return raw != null && PROCESSING_END_MARKER.matcher(raw).find(); + } + + /** Removes the internal final-answer capture markers from model output. */ + public static String visibleCapturedText(String raw) { + if (raw == null || raw.length() == 0) return ""; + String cleaned = raw; + int marker = cleaned.indexOf("", marker + 2); + if (markerEnd < 0) return cleaned.substring(0, marker); + return cleaned.substring(markerEnd + 2); + /* + int eof = cleaned.indexOf("= 0) { + int eofEnd = cleaned.indexOf("\">", eof + 5); + if (eofEnd < 0) return cleaned.substring(0, eof); + cleaned = cleaned.substring(0, eof) + cleaned.substring(eofEnd + 2); + eof = cleaned.indexOf("", start + 2); + if (end < 0) return before; + String after = cleaned.substring(end + 2); + return before + after;*/ + } + private void scheduleFlush() { if (streamRenderScheduled) { return; diff --git a/app/src/main/java/cn/lineai/mvp/ToolConfirmationController.java b/app/src/main/java/cn/lineai/mvp/ToolConfirmationController.java index ab4cb5fb..504a895c 100644 --- a/app/src/main/java/cn/lineai/mvp/ToolConfirmationController.java +++ b/app/src/main/java/cn/lineai/mvp/ToolConfirmationController.java @@ -39,19 +39,40 @@ void continueToolExecution( void executeAcceptedPendingTool(PendingToolExecution pending); String currentConversationId(); + default String executionPermissionScope() { return ""; } + default boolean isPermanentlyAllowed(String scope, ToolCall call) { return false; } + default void rememberPermanentAllowance(String scope, ToolCall call) {} + } private final Callback callback; private final Set sessionAutoConfirmedTools = new HashSet<>(); private final HashMap pendingAgentToolReviews = new HashMap<>(); - private final HashMap pendingAgentToolRequests = new HashMap<>(); + private final java.util.LinkedHashMap pendingAgentToolRequests = new java.util.LinkedHashMap<>(); private String sessionAutoConfirmedConversationId = ""; private PendingToolExecution pendingToolExecution; + private String pendingExecutionScope = ""; ToolConfirmationController(Callback callback) { this.callback = callback; } + cn.lineai.model.ToolApproval pendingToolApproval() { + if (pendingToolExecution != null && callback.isActiveGeneration(pendingToolExecution.getGenerationId())) { + ToolCall call = pendingToolExecution.getToolCall(); + if (call != null) return new cn.lineai.model.ToolApproval(call.getId(), call, + SHELL_EXECUTE_TOOL.equals(call.getName()) && !pendingExecutionScope.isEmpty()); + } + synchronized (pendingAgentToolRequests) { + for (java.util.Map.Entry entry : pendingAgentToolRequests.entrySet()) { + ToolCall call = entry.getValue().call; + return new cn.lineai.model.ToolApproval(entry.getKey(), call, + SHELL_EXECUTE_TOOL.equals(call.getName()) && !callback.executionPermissionScope().isEmpty()); + } + } + return null; + } + void handleToolReview(String state) { PendingToolExecution pending = pendingToolExecution; if (pending == null) { @@ -71,10 +92,12 @@ boolean handleAgentToolReview(String toolCallId, String state) { if (pending == null) { return false; } - pending.resolve(state); - if (isSessionAutoReview(state, pending.toolCall())) { - rememberSessionAutoConfirmation(pending.toolCall()); + if (!pending.scope.equals(callback.executionPermissionScope())) state = "rejected"; + if ("permanent".equals(state) && SHELL_EXECUTE_TOOL.equals(pending.toolCall().getName())) { + callback.rememberPermanentAllowance(pending.scope, pending.toolCall()); } + if (isSessionAutoReview(state, pending.toolCall())) rememberSessionAutoConfirmation(pending.toolCall()); + pending.resolve(state); return true; } @@ -86,9 +109,13 @@ String awaitAgentToolReview( if (displayToolCallId == null || displayToolCallId.length() == 0) { return "accepted"; } - PendingAgentToolReview pending = new PendingAgentToolReview(call); + PendingAgentToolReview pending; synchronized (pendingAgentToolReviews) { - pendingAgentToolReviews.put(displayToolCallId, pending); + pending = pendingAgentToolReviews.get(displayToolCallId); + if (pending == null) { + pending = new PendingAgentToolReview(call, callback.executionPermissionScope()); + pendingAgentToolReviews.put(displayToolCallId, pending); + } } try { while (true) { @@ -139,6 +166,7 @@ void acceptAgentToolReview(String toolCallId, String state) { void setPendingToolExecution(PendingToolExecution pending) { this.pendingToolExecution = pending; + this.pendingExecutionScope = callback.executionPermissionScope(); } PendingToolExecution getPendingToolExecution() { @@ -146,6 +174,11 @@ PendingToolExecution getPendingToolExecution() { } void putPendingAgentToolRequest(String toolCallId, PendingAgentToolRequest request) { + synchronized (pendingAgentToolReviews) { + if (!pendingAgentToolReviews.containsKey(toolCallId)) { + pendingAgentToolReviews.put(toolCallId, new PendingAgentToolReview(request.call, callback.executionPermissionScope())); + } + } synchronized (pendingAgentToolRequests) { pendingAgentToolRequests.put(toolCallId, request); } @@ -182,6 +215,7 @@ boolean isSessionAutoConfirmed(ToolCall call) { if (call == null) { return false; } + if (callback.isPermanentlyAllowed(callback.executionPermissionScope(), call)) return true; synchronized (sessionAutoConfirmedTools) { syncSessionAutoToolConfirmationsLocked(); return sessionAutoConfirmedTools.contains(call.getName()); @@ -206,6 +240,7 @@ private void handlePendingToolReview(PendingToolExecution pending, String state) pendingToolExecution = null; return; } + if (!pendingExecutionScope.equals(callback.executionPermissionScope())) state = "rejected"; boolean sessionAutoAccepted = isSessionAutoReview(state, pending.getToolCall()); String normalizedState = "rejected".equals(state) ? "rejected" : "accepted"; pendingToolExecution = null; @@ -232,6 +267,9 @@ private void handlePendingToolReview(PendingToolExecution pending, String state) ); return; } + if ("permanent".equals(state) && SHELL_EXECUTE_TOOL.equals(pending.getToolCall().getName())) { + callback.rememberPermanentAllowance(pendingExecutionScope, pending.getToolCall()); + } if (sessionAutoAccepted) { rememberSessionAutoConfirmation(pending.getToolCall()); } @@ -295,10 +333,13 @@ private static String rejectedToolMessage(ToolCall call) { static final class PendingAgentToolReview { private final CountDownLatch latch = new CountDownLatch(1); private final ToolCall toolCall; + private final String scope; private String state = "accepted"; - PendingAgentToolReview(ToolCall toolCall) { + PendingAgentToolReview(ToolCall toolCall) { this(toolCall, ""); } + PendingAgentToolReview(ToolCall toolCall, String scope) { this.toolCall = toolCall; + this.scope = scope; } boolean await(long timeoutMs) throws InterruptedException { diff --git a/app/src/main/java/cn/lineai/mvp/ToolRunController.java b/app/src/main/java/cn/lineai/mvp/ToolRunController.java index f24a5729..965d96ad 100644 --- a/app/src/main/java/cn/lineai/mvp/ToolRunController.java +++ b/app/src/main/java/cn/lineai/mvp/ToolRunController.java @@ -30,6 +30,13 @@ public ToolExecutionCoordinator.ToolExecutionPlan createPlan(List tool return executionCoordinator.createPlan(toolCalls); } + boolean isCommandPermanentlyAllowed(String scope, ToolCall call) { + return toolSettingsRepository != null && toolSettingsRepository.isCommandPermanentlyAllowed(scope, call); + } + void allowCommandPermanently(String scope, ToolCall call) { + if (toolSettingsRepository != null) toolSettingsRepository.allowCommandPermanently(scope, call); + } + public ArrayList orderedResults(List toolCalls, HashMap resultById) { ArrayList ordered = new ArrayList<>(); if (toolCalls == null || resultById == null) { diff --git a/app/src/main/java/cn/lineai/mvp/agent/AgentExecutionController.java b/app/src/main/java/cn/lineai/mvp/agent/AgentExecutionController.java index d5eef1c5..a5f8f63a 100644 --- a/app/src/main/java/cn/lineai/mvp/agent/AgentExecutionController.java +++ b/app/src/main/java/cn/lineai/mvp/agent/AgentExecutionController.java @@ -32,7 +32,6 @@ import cn.lineai.tool.ToolRegistry; import cn.lineai.tool.builtin.AgentTool; import cn.lineai.tool.builtin.AgentPipelineTool; -import cn.lineai.tool.builtin.FileDeleteTool; import cn.lineai.tool.builtin.FileEditTool; import cn.lineai.tool.builtin.FileToolPathPolicy; import cn.lineai.tool.builtin.FileWriteTool; @@ -989,7 +988,7 @@ private boolean requiresToolConfirmation(ToolCall call) { && tool.needsConfirmation() && !toolReviewAwaiter.isAutoConfirmed(call) && toolSettingsRepository.canExecuteTool(tool.getName(), tool.getCategory()).isAllowed() - && (FileDeleteTool.NAME.equals(tool.getName()) || toolSettingsRepository.needsConfirmation(tool.getName())); + && toolSettingsRepository.needsConfirmation(tool.getName()); } public String agentRolePrompt(String type) { diff --git a/app/src/main/java/cn/lineai/ui/MainChatView.java b/app/src/main/java/cn/lineai/ui/MainChatView.java index 37e04990..be5874ce 100644 --- a/app/src/main/java/cn/lineai/ui/MainChatView.java +++ b/app/src/main/java/cn/lineai/ui/MainChatView.java @@ -122,6 +122,7 @@ public interface DocumentCreateCallback { private final LinearLayout contentView; private final ChatMessageListView messageListView; private final ComposerView composerView; + private final cn.lineai.ui.component.ToolApprovalView toolApprovalView; private final DrawerView drawerView; private final BottomSheetView bottomSheetView; private final DirectoryPickerSheetView directoryPickerSheetView; @@ -153,7 +154,7 @@ public MainChatView(Context context, MainUiController presenter) { MainChatViewLayoutBuilder.Result layout = MainChatViewLayoutBuilder.build(context); contentView = layout.contentView; screenHost = layout.screenHost; - addView(contentView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + addView(contentView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, android.view.Gravity.CENTER_HORIZONTAL)); addView(screenHost, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); headerView = new HeaderView(context); @@ -289,6 +290,11 @@ public void onImagePickerClick() { MainChatView.this.presenter.onImagePickerRequested(); } + @Override public void onPermissionClick() { MainChatView.this.presenter.onPermissionClick(); } + @Override public void onProjectClick() { MainChatView.this.presenter.onProjectClick(); } + @Override public void onSettingsClick() { MainChatView.this.presenter.onSheetOptionSelected("settings"); } + @Override public void onMoreClick() { MainChatView.this.presenter.onMoreClick(); } + @Override public void onModeChanged(String mode) { MainChatView.this.presenter.onChatModeChanged(mode); @@ -326,6 +332,10 @@ public int onQueryModelCount(String baseUrl) throws Exception { ViewGroup.LayoutParams.WRAP_CONTENT )); + toolApprovalView = new cn.lineai.ui.component.ToolApprovalView(context); + toolApprovalView.setToolReviewListener((toolCallId, state, diffId) -> presenter.onToolReview(toolCallId, state, diffId)); + contentView.addView(toolApprovalView, new LinearLayout.LayoutParams(-1, -2)); + drawerView = new DrawerView(context); drawerView.setListener(new DrawerView.Listener() { @Override @@ -469,6 +479,12 @@ private void registerScreenFactories() { screenRegistry.register(new ScreenFactories.AgentEditScreenFactory()); screenRegistry.register(new ScreenFactories.McpEditScreenFactory()); screenRegistry.register(new ScreenFactories.ExtensionDetailScreenFactory()); + screenRegistry.register(new ScreenFactories.SkillStoreScreenFactory()); + screenRegistry.register(new ScreenFactories.SkillHubLoginScreenFactory()); + screenRegistry.register(new ScreenFactories.SkillHubCenterScreenFactory()); + screenRegistry.register(new ScreenFactories.SkillHubWebScreenFactory()); + screenRegistry.register(new ScreenFactories.SkillHubPublishScreenFactory()); + screenRegistry.register(new ScreenFactories.SkillStoreDetailScreenFactory()); screenRegistry.register(new ScreenFactories.BrowserScreenFactory()); screenRegistry.register(new ScreenFactories.BrowserPrefixScreenFactory()); screenRegistry.register(new ScreenFactories.ShellCommandScreenFactory()); @@ -480,6 +496,8 @@ public void render(ChatUiState state) { headerView.render(state); messageListView.render(state); composerView.render(state); + toolApprovalView.bind(state == null ? null : state.getToolApproval()); + composerView.setVisibility(state != null && state.getToolApproval() != null ? GONE : VISIBLE); if (drawerView.getVisibility() == VISIBLE) { renderDrawer(state); } diff --git a/app/src/main/java/cn/lineai/ui/component/AboutScreenView.java b/app/src/main/java/cn/lineai/ui/component/AboutScreenView.java index 61688a8a..d371560e 100644 --- a/app/src/main/java/cn/lineai/ui/component/AboutScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/AboutScreenView.java @@ -26,7 +26,7 @@ public AboutScreenView(Context context, Listener listener) { super(context, context.getString(R.string.screen_about_title), listener::onBack, null); VersionInfo versionInfo = readVersionInfo(context); LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 28, 8, 28, 48); LinearLayout header = new LinearLayout(context); header.setOrientation(VERTICAL); diff --git a/app/src/main/java/cn/lineai/ui/component/ActionRowView.java b/app/src/main/java/cn/lineai/ui/component/ActionRowView.java index 30d00b14..d804ad43 100644 --- a/app/src/main/java/cn/lineai/ui/component/ActionRowView.java +++ b/app/src/main/java/cn/lineai/ui/component/ActionRowView.java @@ -14,17 +14,17 @@ public ActionRowView(Context context, int iconType, String label, String desc, b super(context); setOrientation(HORIZONTAL); setGravity(Gravity.CENTER_VERTICAL); - setMinimumHeight(LineTheme.dp(context, 68)); + setMinimumHeight(LineTheme.dp(context, 56)); LineTheme.padding(this, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, destructive ? LineTheme.DANGER_MUTED : LineTheme.ACCENT_MUTED, 8)); + IconButtonView icon = new IconButtonView(context, iconType); - icon.setIconColor(destructive ? LineTheme.DANGER : LineTheme.ACCENT); - icon.setIconSizeDp(36, 20); + icon.setIconColor(destructive ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); + icon.setIconSizeDp(24, 20); icon.setClickable(false); - iconWrap.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36), Gravity.CENTER)); - addView(iconWrap, new LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36))); + iconWrap.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 24), Gravity.CENTER)); + addView(iconWrap, new LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 24))); LinearLayout textWrap = new LinearLayout(context); textWrap.setOrientation(VERTICAL); @@ -37,10 +37,10 @@ public ActionRowView(Context context, int iconType, String label, String desc, b textWrap.addView(title, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); if (desc != null && desc.length() > 0) { - TextView description = LineTheme.text(context, desc, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + TextView description = LineTheme.text(context, desc, 14, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); description.setLineSpacing(LineTheme.dp(context, 3), 1f); LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, 2); + descParams.topMargin = LineTheme.dp(context, 6); textWrap.addView(description, descParams); } @@ -54,6 +54,7 @@ public ActionRowView(Context context, int iconType, String label, String desc, b if (onClick != null) { setClickable(true); + setBackground(LineTheme.pressable(context)); setOnClickListener(v -> onClick.run()); } } diff --git a/app/src/main/java/cn/lineai/ui/component/AdaptiveActionsView.java b/app/src/main/java/cn/lineai/ui/component/AdaptiveActionsView.java new file mode 100644 index 00000000..f1bd57d9 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/AdaptiveActionsView.java @@ -0,0 +1,33 @@ +package cn.lineai.ui.component; +import android.content.Context; +import android.view.View; +import android.widget.LinearLayout; +import cn.lineai.ui.theme.LineTheme; +import java.util.IdentityHashMap; +/** Stacks actions only when their complete labels cannot fit in a horizontal row. */ +public final class AdaptiveActionsView extends LinearLayout { + private final IdentityHashMap original = new IdentityHashMap<>(); + public AdaptiveActionsView(Context context) { super(context); } + @Override protected void onMeasure(int width, int height) { + int available = MeasureSpec.getSize(width) - getPaddingLeft() - getPaddingRight(); + int total = 0; + for (int i=0;iavailable; + setOrientation(stack?VERTICAL:HORIZONTAL); + for(int i=0;i listener.onOpen(id)); - card.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); - LineTheme.padding(card, LineTheme.LG, LineTheme.MD, LineTheme.MD, LineTheme.MD); - LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - cardParams.bottomMargin = LineTheme.dp(context, LineTheme.SM); - content.addView(card, cardParams); - - FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 12)); - IconButtonView icon = new IconButtonView(context, iconType); - icon.setIconColor(LineTheme.ACCENT); - icon.setIconSizeDp(44, 22); - icon.setClickable(false); - iconWrap.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44), Gravity.CENTER)); - card.addView(iconWrap, new LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44))); - - LinearLayout text = new LinearLayout(context); - text.setOrientation(VERTICAL); - LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - textParams.leftMargin = LineTheme.dp(context, LineTheme.MD); - textParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - card.addView(text, textParams); - - LinearLayout titleRow = new LinearLayout(context); - titleRow.setOrientation(HORIZONTAL); - titleRow.setGravity(Gravity.CENTER_VERTICAL); - text.addView(titleRow, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - TextView titleView = LineTheme.text(context, title, LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); - titleRow.addView(titleView, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - TextView badgeView = LineTheme.text(context, badge, LineTheme.FONT_XS, LineTheme.ACCENT, Typeface.BOLD); - badgeView.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 999)); - LineTheme.padding(badgeView, LineTheme.SM, 3, LineTheme.SM, 3); - LinearLayout.LayoutParams badgeParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - badgeParams.leftMargin = LineTheme.dp(context, LineTheme.SM); - titleRow.addView(badgeView, badgeParams); - - TextView descView = LineTheme.text(context, desc, LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - descView.setLineSpacing(LineTheme.dp(context, 3), 1f); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, LineTheme.XS); - text.addView(descView, descParams); - - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - chevron.setIconColor(LineTheme.TEXT_TERTIARY); - chevron.setIconSizeDp(20, 17); - chevron.setClickable(false); - card.addView(chevron, new LayoutParams(LineTheme.dp(context, 20), LineTheme.dp(context, 20))); + CardViewHelper.addCard(content, id, title, desc, badge, iconType, listener::onOpen); } } diff --git a/app/src/main/java/cn/lineai/ui/component/AgentExtensionEditScreenView.java b/app/src/main/java/cn/lineai/ui/component/AgentExtensionEditScreenView.java index 945d5d94..0aa410eb 100644 --- a/app/src/main/java/cn/lineai/ui/component/AgentExtensionEditScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/AgentExtensionEditScreenView.java @@ -350,7 +350,7 @@ private void addForm(LinearLayout content, String title, android.view.View first Context context = content.getContext(); LinearLayout group = new LinearLayout(context); group.setOrientation(LinearLayout.VERTICAL); - group.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); + group.setBackground(null); LineTheme.padding(group, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); group.addView(first, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout.LayoutParams secondParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); @@ -424,7 +424,7 @@ private static final class McpOption { } } - private static final class GenerateButtonView extends LinearLayout { + private static final class GenerateButtonView extends ScreenSurfaceView { private final ProgressBar progressBar; private final IconButtonView icon; private final TextView label; diff --git a/app/src/main/java/cn/lineai/ui/component/AssistantMessageView.java b/app/src/main/java/cn/lineai/ui/component/AssistantMessageView.java index 93ef5969..c97720eb 100644 --- a/app/src/main/java/cn/lineai/ui/component/AssistantMessageView.java +++ b/app/src/main/java/cn/lineai/ui/component/AssistantMessageView.java @@ -47,7 +47,7 @@ public AssistantMessageView(Context context) { super(context); setOrientation(VERTICAL); setGravity(Gravity.START); - LineTheme.padding(this, LineTheme.LG, 0, LineTheme.LG, LineTheme.MD); + LineTheme.padding(this, 16, 0, 16, 28); defaultPaddingLeft = getPaddingLeft(); defaultPaddingTop = getPaddingTop(); defaultPaddingRight = getPaddingRight(); @@ -116,9 +116,15 @@ public void onMultiSelect() { } } }); - LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 22)); + LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 44)); actionParams.topMargin = LineTheme.dp(context, 3); addView(actionBar, actionParams); + actionBar.setVisibility(GONE); + contentView.setOnLongClickListener(v -> { + if (currentMessage == null || currentMessage.isStreaming()) return false; + actionBar.setVisibility(actionBar.getVisibility() == VISIBLE ? GONE : VISIBLE); + return true; + }); } public void bind(ChatMessage message) { @@ -182,14 +188,10 @@ public void bind(ChatMessage message, boolean thinkingAutoExpand, boolean thinki contentView.setMarkdown(content); } } + installMessageLongPress(contentView); bindToolCalls(message); - actionBar.setVisibility(message.isStreaming() || message.getContent().trim().isEmpty() ? GONE : VISIBLE); + if (!lastMessageId.equals(messageId) || message.isStreaming()) actionBar.setVisibility(GONE); setWorkingStatusVisible(message.isStreaming(), WorkingStatusView.isThinking(safeReasoning, content)); - if (!lastAnimatedMessageId.equals(messageId)) { - lastAnimatedMessageId = messageId; - setAlpha(0f); - animate().alpha(1f).setDuration(ENTRANCE_FADE_MS).start(); - } lastMessageId = messageId; lastReasoning = safeReasoning; lastContent = content; @@ -200,6 +202,21 @@ public void bind(ChatMessage message, boolean thinkingAutoExpand, boolean thinki lastCompactStatus = ""; } + private void installMessageLongPress(android.view.View view) { + view.setOnLongClickListener(v -> { + if (currentMessage == null || currentMessage.isStreaming()) return false; + actionBar.setVisibility(actionBar.getVisibility() == VISIBLE ? GONE : VISIBLE); + return true; + }); + if (view instanceof android.view.ViewGroup) { + android.view.ViewGroup group = (android.view.ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + android.view.View child = group.getChildAt(i); + if (child instanceof android.widget.TextView || child instanceof cn.lineai.ui.markdown.MarkdownView) installMessageLongPress(child); + } + } + } + private void setWorkingStatusVisible(boolean visible, boolean thinking) { if (visible) { workingStatusView.bind(thinking); diff --git a/app/src/main/java/cn/lineai/ui/component/AssistantTurnView.java b/app/src/main/java/cn/lineai/ui/component/AssistantTurnView.java new file mode 100644 index 00000000..23f38539 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/AssistantTurnView.java @@ -0,0 +1,322 @@ +package cn.lineai.ui.component; + +import android.content.Context; +import android.graphics.Typeface; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import cn.lineai.R; +import cn.lineai.model.ChatMessage; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.tool.ui.ToolCallBlockView; +import cn.lineai.ui.markdown.MarkdownLinkHandler; +import cn.lineai.ui.markdown.MarkdownView; +import cn.lineai.ui.model.ConversationTimeline; +import cn.lineai.ui.model.ProcessingDuration; +import cn.lineai.ui.model.ConversationTimeline.Block; +import cn.lineai.ui.model.ConversationTimeline.Operation; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import cn.lineai.ui.theme.ThinkingBlockView; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** A response with manually disclosed work, and a separate, always visible final answer. */ +public final class AssistantTurnView extends LinearLayout { + private final LinearLayout process; + private final LinearLayout processToggle; + private final View processRule; + private final LinearLayout changes; + private final LinearLayout files; + private final TextView processLabel; + private final IconButtonView processArrow; + private final TextView filesLabel; + private final IconButtonView filesArrow; + private final AssistantMessageView answer; + private final Map blocks = new HashMap<>(); + private final Map fileViews = new HashMap<>(); + private Map disclosure; + private ConversationTimeline.Row row; + private ToolReviewListener reviewer; + private MarkdownLinkHandler links; + private String projectPath = ""; + private String identity = ""; + private boolean codeWrap; + private boolean generating; + private boolean hasTools; + private final Runnable durationTick = this::updateProcessLabel; + + public AssistantTurnView(Context context) { + super(context); + setOrientation(VERTICAL); + LineTheme.padding(this, 16, 0, 16, 32); + LinearLayout toggle = horizontal(); + processToggle = toggle; + toggle.setMinimumHeight(dp(48)); + processLabel = LineTheme.text(context, "", 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + toggle.addView(processLabel); + processArrow = icon(IconButtonView.CHEVRON_RIGHT); + toggle.addView(processArrow, new LayoutParams(dp(28), dp(32))); + toggle.setFocusable(true); + toggle.setOnClickListener(v -> { + disclosure.put(identity + ":process", !isOpen(identity + ":process")); + renderProcess(); + }); + addView(toggle, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + View rule = new View(context); + processRule = rule; + rule.setBackgroundColor(LineTheme.BORDER); + LayoutParams ruleParams = new LayoutParams(LayoutParams.MATCH_PARENT, dp(1)); + ruleParams.bottomMargin = dp(20); + addView(rule, ruleParams); + process = vertical(); + addView(process, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + answer = new AssistantMessageView(context); + answer.setPadding(0, 0, 0, 0); + addView(answer, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + changes = vertical(); + LayoutParams changeParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + changeParams.topMargin = dp(24); + addView(changes, changeParams); + View changesRule = new View(context); + changesRule.setBackgroundColor(LineTheme.BORDER); + changes.addView(changesRule, new LayoutParams(LayoutParams.MATCH_PARENT, dp(1))); + LinearLayout summary = horizontal(); + summary.setMinimumHeight(dp(64)); + summary.addView(icon(IconButtonView.FILE_PEN_LINE), new LayoutParams(dp(26), dp(32))); + filesLabel = LineTheme.text(context, "", 14, LineTheme.TEXT, Typeface.NORMAL); + LayoutParams labelParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1); + labelParams.leftMargin = dp(6); + summary.addView(filesLabel, labelParams); + TextView review = LineTheme.text(context, context.getString(R.string.chat_review_changes), 14, LineTheme.TEXT, Typeface.NORMAL); + review.setGravity(Gravity.CENTER); + review.setMinHeight(dp(48)); + LineTheme.padding(review, 12, 0, 0, 0); + review.setOnClickListener(v -> { + disclosure.put(identity + ":files", true); + for (Operation operation : changedFiles()) disclosure.put("review:" + operation.call.getId(), true); + renderFiles(); + }); + summary.addView(review); + filesArrow = icon(IconButtonView.CHEVRON_RIGHT); + summary.addView(filesArrow, new LayoutParams(dp(24), dp(32))); + summary.setFocusable(true); + summary.setOnClickListener(v -> { + disclosure.put(identity + ":files", !isOpen(identity + ":files")); + renderFiles(); + }); + changes.addView(summary, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + files = vertical(); + changes.addView(files, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + public void bind(ConversationTimeline.Row row, Map disclosure, String projectPath, + ToolReviewListener reviewer, MarkdownLinkHandler links, MessageActionListener actions, + boolean codeWrap, boolean generating) { + String nextIdentity = row.first.getId(); + if (!identity.equals(nextIdentity)) { + process.removeAllViews(); blocks.clear(); files.removeAllViews(); fileViews.clear(); + } + identity = nextIdentity; + this.row = row; this.disclosure = disclosure; this.projectPath = projectPath; + this.reviewer = reviewer; this.links = links; this.codeWrap = codeWrap; + this.generating = generating; + hasTools = row.isTurn; + setProcessVisibility(hasTools); + updateProcessLabel(); + if (row.answer != null) { + answer.setVisibility(VISIBLE); + answer.setMarkdownLinkHandler(links); + answer.setMessageActionListener(actions); + ChatMessage message = row.answer; + answer.bind(message, false, true, codeWrap); + } else answer.setVisibility(GONE); + renderProcess(); + renderFiles(); + } + + private void updateProcessLabel() { + removeCallbacks(durationTick); + if (row == null) return; + boolean active = generating && row.processingFinishedAt == 0 + && (row.processingStartedAt > 0 || row.running || row.pending); + int status = row.pending && active ? R.string.chat_process_pending + : active ? R.string.chat_process_running : R.string.chat_process_done; + String label = getContext().getString(status); + if (row.processingStartedAt > 0) { + long end = row.processingFinishedAt > 0 ? row.processingFinishedAt : System.currentTimeMillis(); + label += " " + ProcessingDuration.format(end - row.processingStartedAt); + } + processLabel.setText(label); + processLabel.setContentDescription(label + ", " + getContext().getString(isOpen(identity + ":process") + ? R.string.chat_collapse : R.string.chat_expand)); + if (active && row.processingStartedAt > 0 && isAttachedToWindow()) postDelayed(durationTick, 1000); + } + + @Override protected void onAttachedToWindow() { + super.onAttachedToWindow(); + updateProcessLabel(); + } + + @Override protected void onDetachedFromWindow() { + removeCallbacks(durationTick); + super.onDetachedFromWindow(); + } + + private void setProcessVisibility(boolean visible) { + processToggle.setVisibility(visible ? VISIBLE : GONE); + processRule.setVisibility(visible ? VISIBLE : GONE); + if (!visible) process.setVisibility(GONE); + } + + private void renderProcess() { + boolean expanded = hasTools && isOpen(identity + ":process"); + process.setVisibility(expanded ? VISIBLE : GONE); + processArrow.setIconType(expanded ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + processLabel.setContentDescription(processLabel.getText() + ", " + getContext().getString(expanded ? R.string.chat_collapse : R.string.chat_expand)); + if (!expanded) return; + ArrayList children = new ArrayList<>(); + for (Block block : row.process) { + View view = blocks.get(block.id); + if (block.reasoning) { + ThinkingBlockView thought = view instanceof ThinkingBlockView ? (ThinkingBlockView) view : new ThinkingBlockView(getContext()); + ChatMessage owner = null; + for (ChatMessage message : row.messages) if (block.id.equals(message.getId() + ":reasoning")) owner = message; + thought.bind(identity + ":" + block.id, block.text, owner != null && owner.isStreaming() && generating, false, true); + view = thought; + } else if (block.isAgent()) { + ToolCallBlockView direct = view instanceof ToolCallBlockView ? (ToolCallBlockView) view : new ToolCallBlockView(getContext()); + bindOperation(direct, block.operations.get(0), "call:" + block.operations.get(0).call.getId()); + view = direct; + } else if (block.isTools()) { + ToolGroupView group = view instanceof ToolGroupView ? (ToolGroupView) view : new ToolGroupView(getContext()); + group.bind(block); + view = group; + } else { + MarkdownView text = view instanceof MarkdownView ? (MarkdownView) view : new MarkdownView(getContext()); + text.setCodeWrapEnabled(codeWrap); + text.setLinkHandler(links); + if (!block.text.equals(text.getTag())) { + text.setMarkdown(block.text); + text.setTag(block.text); + } + view = text; + } + blocks.put(block.id, view); + children.add(view); + } + reconcile(process, children, 8); + } + + private List changedFiles() { + LinkedHashMap edits = new LinkedHashMap<>(); + for (Block block : row.process) for (Operation operation : block.operations) { + if (operation.result != null && !operation.result.getDiffId().isEmpty()) { + // Keep every diff reviewable, including multiple edits to the same file. + edits.put(operation.result.getDiffId(), operation); + } + } + return new ArrayList<>(edits.values()); + } + + private void renderFiles() { + List edits = changedFiles(); + changes.setVisibility(edits.isEmpty() || row.answer == null ? GONE : VISIBLE); + java.util.Set paths = new java.util.HashSet<>(); + for (Operation operation : edits) { + org.json.JSONObject input = cn.lineai.tool.ui.ToolCallUtils.parseInput(operation.call); + paths.add(input.optString("file_path", input.optString("path", operation.result.getDiffId()))); + } + filesLabel.setText(getContext().getString(R.string.chat_files_changed, paths.size())); + boolean expanded = isOpen(identity + ":files"); + files.setVisibility(expanded ? VISIBLE : GONE); + filesArrow.setIconType(expanded ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + if (!expanded) return; + ArrayList children = new ArrayList<>(); + for (Operation operation : edits) { + String id = operation.call.getId(); + ToolCallBlockView view = fileViews.get(id); + if (view == null) { view = new ToolCallBlockView(getContext()); fileViews.put(id, view); } + bindOperation(view, operation, "review:" + id); + children.add(view); + } + reconcile(files, children, 12); + } + + private void bindOperation(ToolCallBlockView view, Operation operation, String key) { + view.setProjectPath(projectPath); + view.setToolReviewListener(reviewer); + view.setExpansionState(disclosure, key); + view.bind(operation.call, operation.result); + } + + private final class ToolGroupView extends LinearLayout { + final TextView label; + final IconButtonView arrow; + final LinearLayout content; + final Map views = new HashMap<>(); + Block block; + ToolGroupView(Context context) { + super(context); setOrientation(VERTICAL); + LinearLayout header = horizontal(); header.setMinimumHeight(dp(48)); + header.addView(icon(IconButtonView.TERMINAL), new LayoutParams(dp(24), dp(32))); + label = LineTheme.text(context, "", 14, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LayoutParams labelParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + labelParams.leftMargin = dp(6); header.addView(label, labelParams); + arrow = icon(IconButtonView.CHEVRON_RIGHT); header.addView(arrow, new LayoutParams(dp(28), dp(32))); + header.setFocusable(true); + header.setOnClickListener(v -> { disclosure.put(block.id, !isOpen(block.id)); bind(block); }); + addView(header, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + content = vertical(); addView(content, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + void bind(Block block) { + this.block = block; + label.setText(getContext().getString(R.string.chat_tools_count, block.operations.size())); + boolean expanded = isOpen(block.id); + arrow.setIconType(expanded ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + content.setVisibility(expanded ? VISIBLE : GONE); + if (!expanded) return; + ArrayList children = new ArrayList<>(); + for (Block step : block.steps) { + if (step.reasoning) continue; + View view = views.get(step.id); + if (step.isTools()) { + Operation operation = step.operations.get(0); + ToolCallBlockView callView = view instanceof ToolCallBlockView ? (ToolCallBlockView) view : new ToolCallBlockView(getContext()); + bindOperation(callView, operation, "call:" + operation.call.getId()); + view = callView; + } else { + MarkdownView text = view instanceof MarkdownView ? (MarkdownView) view : new MarkdownView(getContext()); + text.setCodeWrapEnabled(codeWrap); text.setLinkHandler(links); text.setTextScale(.875f); + if (!step.text.equals(text.getTag())) { text.setMarkdown(step.text); text.setTag(step.text); } + view = text; + } + views.put(step.id, view); children.add(view); + } + reconcile(content, children, 8); + } + } + + private void reconcile(LinearLayout parent, List children, int gap) { + for (int i = parent.getChildCount() - 1; i >= 0; i--) if (!children.contains(parent.getChildAt(i))) parent.removeViewAt(i); + for (int i = 0; i < children.size(); i++) { + View child = children.get(i); + if (parent.getChildAt(i) == child) continue; + if (child.getParent() == parent) parent.removeView(child); + LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.bottomMargin = dp(gap); parent.addView(child, i, params); + } + } + private boolean isOpen(String key) { return Boolean.TRUE.equals(disclosure.get(key)); } + private int dp(int value) { return LineTheme.dp(getContext(), value); } + private LinearLayout horizontal() { LinearLayout row = new LinearLayout(getContext()); row.setGravity(Gravity.CENTER_VERTICAL); return row; } + private LinearLayout vertical() { LinearLayout column = new LinearLayout(getContext()); column.setOrientation(VERTICAL); return column; } + private IconButtonView icon(int type) { + IconButtonView icon = new IconButtonView(getContext(), type); icon.setIconSizeDp(28, 16); + icon.setIconColor(LineTheme.TEXT_SECONDARY); icon.setClickable(false); icon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); return icon; + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/AttachmentPickerSheetView.java b/app/src/main/java/cn/lineai/ui/component/AttachmentPickerSheetView.java index 4d7293b3..5f15a207 100644 --- a/app/src/main/java/cn/lineai/ui/component/AttachmentPickerSheetView.java +++ b/app/src/main/java/cn/lineai/ui/component/AttachmentPickerSheetView.java @@ -51,17 +51,20 @@ public AttachmentPickerSheetView(Context context) { backdrop.setOnClickListener(v -> close()); addView(backdrop, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); - panel = new LinearLayout(context); + panel = new InsetSheetLayout(context); panel.setOrientation(LinearLayout.VERTICAL); - panel.setBackground(LineTheme.roundedTop(context, LineTheme.SURFACE_ELEVATED, 16)); + panel.setClipToOutline(true); + panel.setBackground(LineTheme.roundedStroke(context, LineTheme.BG, 24, LineTheme.BORDER_LIGHT)); FrameLayout.LayoutParams panelParams = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 560)); - panelParams.gravity = Gravity.BOTTOM; + panelParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + panelParams.leftMargin = panelParams.rightMargin = LineTheme.dp(context, 16); + panelParams.bottomMargin = LineTheme.dp(context, 16); addView(panel, panelParams); LinearLayout header = new LinearLayout(context); header.setOrientation(LinearLayout.HORIZONTAL); header.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(header, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); + LineTheme.padding(header, 20, 20, 20, 16); panel.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout titles = new LinearLayout(context); @@ -80,10 +83,10 @@ public AttachmentPickerSheetView(Context context) { IconButtonView close = new IconButtonView(context, IconButtonView.CLOSE); close.setIconColor(LineTheme.TEXT_SECONDARY); - close.setIconSizeDp(36, 18); - close.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 18)); + close.setIconSizeDp(48, 18); + close.setOnClickListener(v -> close()); - LinearLayout.LayoutParams closeParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36)); + LinearLayout.LayoutParams closeParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 48), LineTheme.dp(context, 48)); closeParams.leftMargin = LineTheme.dp(context, LineTheme.MD); header.addView(close, closeParams); @@ -96,6 +99,15 @@ public AttachmentPickerSheetView(Context context) { panel.addView(body, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); } + @Override protected void onMeasure(int width, int height) { + int available = Math.max(1, MeasureSpec.getSize(height)-LineTheme.dp(getContext(),64)); + ((InsetSheetLayout)panel).setAvailableHeight(available); + android.view.ViewGroup.LayoutParams params = panel.getLayoutParams(); + int target = Math.min(LineTheme.dp(getContext(),640),available); + if (params.height != target) { params.height = target; panel.setLayoutParams(params); } + super.onMeasure(width,height); + } + public void setListener(Listener listener) { this.listener = listener; } @@ -225,6 +237,7 @@ private void addNodeRow(LinearLayout treeList, FileTreeNode node, int depth, boo LinearLayout row = new LinearLayout(context); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(LineTheme.dp(context, 52)); row.setClickable(true); row.setOnClickListener(v -> { if (listener == null) { diff --git a/app/src/main/java/cn/lineai/ui/component/BottomSheetView.java b/app/src/main/java/cn/lineai/ui/component/BottomSheetView.java index b206b6dd..4acea16c 100644 --- a/app/src/main/java/cn/lineai/ui/component/BottomSheetView.java +++ b/app/src/main/java/cn/lineai/ui/component/BottomSheetView.java @@ -46,14 +46,23 @@ public BottomSheetView(Context context) { backdrop.setOnClickListener(v -> close()); addView(backdrop, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); - panel = new LinearLayout(context); + panel = new InsetSheetLayout(context); panel.setOrientation(LinearLayout.VERTICAL); - panel.setBackground(LineTheme.roundedTop(context, LineTheme.SURFACE_ELEVATED, 16)); + panel.setClipToOutline(true); + panel.setBackground(LineTheme.roundedStroke(context, LineTheme.BG, 24, LineTheme.BORDER_LIGHT)); FrameLayout.LayoutParams panelParams = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - panelParams.gravity = Gravity.BOTTOM; + panelParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + panelParams.leftMargin = panelParams.rightMargin = LineTheme.dp(context, 16); + panelParams.bottomMargin = LineTheme.dp(context, 16); addView(panel, panelParams); } + @Override protected void onMeasure(int width, int height) { + int available = Math.max(1, MeasureSpec.getSize(height)-LineTheme.dp(getContext(),64)); + ((InsetSheetLayout)panel).setAvailableHeight(available); + super.onMeasure(width,height); + } + public void setListener(Listener listener) { this.listener = listener; } @@ -73,7 +82,7 @@ public void show(String title, List options) { LinearLayout header = new LinearLayout(context); header.setGravity(Gravity.CENTER_VERTICAL); header.setOrientation(LinearLayout.HORIZONTAL); - LineTheme.padding(header, LineTheme.LG, 0, LineTheme.LG, LineTheme.MD); + LineTheme.padding(header, 24, 12, 24, 20); TextView titleView = LineTheme.text(context, title, LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); header.addView(titleView, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); panel.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); @@ -82,12 +91,15 @@ public void show(String title, List options) { divider.setBackgroundColor(LineTheme.BORDER_LIGHT); panel.addView(divider, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 1)); - for (SheetOption option : options) { - panel.addView(createOptionRow(option), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + android.widget.ScrollView scroll = new android.widget.ScrollView(context); + scroll.setVerticalScrollBarEnabled(false); + LinearLayout choices = new LinearLayout(context); choices.setOrientation(LinearLayout.VERTICAL); + LineTheme.padding(choices, 0, 0, 0, 16); + if (options != null) for (SheetOption option : options) { + choices.addView(createOptionRow(option), new LinearLayout.LayoutParams(-1, -2)); } - - View bottomInset = new View(context); - panel.addView(bottomInset, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 34))); + scroll.addView(choices, new android.widget.ScrollView.LayoutParams(-1, -2)); + panel.addView(scroll, new LinearLayout.LayoutParams(-1, -2)); openAnimated(); } @@ -159,6 +171,7 @@ private View createOptionContentRow(SheetOption option) { LinearLayout row = new LinearLayout(context); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(LineTheme.dp(context, 52)); row.setBackgroundColor(option.isSelected() ? LineTheme.ACCENT_MUTED : android.graphics.Color.TRANSPARENT); LineTheme.padding(row, LineTheme.LG, 14, LineTheme.LG, 14); row.setClickable(true); @@ -193,7 +206,7 @@ private View createOptionContentRow(SheetOption option) { private void showDeleteConfirmation(SheetOption option) { Context context = getContext(); - AlertDialog dialog = new AlertDialog.Builder(context) + AlertDialog dialog = new LineAlertDialog.Builder(context) .setTitle(context.getString(R.string.drawer_project_remove_title)) .setMessage(context.getString(R.string.drawer_project_remove_message, option.getLabel())) .setNegativeButton(context.getString(R.string.common_cancel), null) diff --git a/app/src/main/java/cn/lineai/ui/component/CardViewHelper.java b/app/src/main/java/cn/lineai/ui/component/CardViewHelper.java index 7b572f48..96336551 100644 --- a/app/src/main/java/cn/lineai/ui/component/CardViewHelper.java +++ b/app/src/main/java/cn/lineai/ui/component/CardViewHelper.java @@ -1,76 +1,12 @@ package cn.lineai.ui.component; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; - -import android.content.Context; -import android.graphics.Typeface; -import android.view.Gravity; -import android.widget.FrameLayout; import android.widget.LinearLayout; -import android.widget.TextView; - public final class CardViewHelper { - - public interface OnCardClickListener { - void onCardClick(String id); - } - - private CardViewHelper() { - } - - public static void addCard(LinearLayout content, String id, String title, String desc, String badge, int iconType, OnCardClickListener clickListener) { - Context context = content.getContext(); - LinearLayout card = new LinearLayout(context); - card.setOrientation(LinearLayout.HORIZONTAL); - card.setGravity(Gravity.CENTER_VERTICAL); - card.setClickable(true); - card.setOnClickListener(v -> clickListener.onCardClick(id)); - card.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); - LineTheme.padding(card, LineTheme.LG, LineTheme.MD, LineTheme.MD, LineTheme.MD); - LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - cardParams.bottomMargin = LineTheme.dp(context, LineTheme.SM); - content.addView(card, cardParams); - - FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 12)); - IconButtonView icon = new IconButtonView(context, iconType); - icon.setIconColor(LineTheme.ACCENT); - icon.setIconSizeDp(44, 22); - icon.setClickable(false); - iconWrap.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44), Gravity.CENTER)); - card.addView(iconWrap, new LinearLayout.LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44))); - - LinearLayout text = new LinearLayout(context); - text.setOrientation(LinearLayout.VERTICAL); - LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); - textParams.leftMargin = LineTheme.dp(context, LineTheme.MD); - textParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - card.addView(text, textParams); - - LinearLayout titleRow = new LinearLayout(context); - titleRow.setOrientation(LinearLayout.HORIZONTAL); - titleRow.setGravity(Gravity.CENTER_VERTICAL); - text.addView(titleRow, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - - TextView titleView = LineTheme.text(context, title, LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); - titleRow.addView(titleView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - TextView badgeView = LineTheme.text(context, badge, LineTheme.FONT_XS, LineTheme.ACCENT, Typeface.BOLD); - badgeView.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 999)); - LineTheme.padding(badgeView, LineTheme.SM, 3, LineTheme.SM, 3); - LinearLayout.LayoutParams badgeParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - badgeParams.leftMargin = LineTheme.dp(context, LineTheme.SM); - titleRow.addView(badgeView, badgeParams); - - TextView descView = LineTheme.text(context, desc, LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - descView.setLineSpacing(LineTheme.dp(context, 3), 1f); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, LineTheme.XS); - text.addView(descView, descParams); - - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - chevron.setIconColor(LineTheme.TEXT_TERTIARY); - chevron.setIconSizeDp(20, 17); - chevron.setClickable(false); - card.addView(chevron, new LinearLayout.LayoutParams(LineTheme.dp(context, 20), LineTheme.dp(context, 20))); + public interface OnCardClickListener { void onCardClick(String id); } + private CardViewHelper() { } + public static void addCard(LinearLayout content, String id, String title, String desc, String badge, int icon, OnCardClickListener listener) { + ActionRowView row = new ActionRowView(content.getContext(), icon, title, desc, false, true, () -> listener.onCardClick(id)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-1, -2); + params.bottomMargin = cn.lineai.ui.theme.LineTheme.dp(content.getContext(), 12); + content.addView(row, params); } } diff --git a/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java b/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java index 7620c356..d537f941 100644 --- a/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java +++ b/app/src/main/java/cn/lineai/ui/component/ChatMessageListView.java @@ -20,6 +20,7 @@ import android.widget.TextView; import cn.lineai.R; import cn.lineai.model.ChatMessage; +import cn.lineai.ui.model.ConversationTimeline; import cn.lineai.model.ChatUiState; import cn.lineai.model.InputAttachment; import cn.lineai.tool.ToolReviewListener; @@ -352,42 +353,26 @@ private boolean isAtBottom() { return lastChild.getBottom() <= viewportBottom + LineTheme.dp(getContext(), 2); } - private static View createConfigureState(Context context, EmptyStateListener listener) { + private static View createConfigureState(Context context, EmptyStateListener listener, boolean configure) { LinearLayout box = new LinearLayout(context); box.setOrientation(LinearLayout.VERTICAL); - box.setGravity(Gravity.CENTER); - LineTheme.padding(box, LineTheme.XL, 80, LineTheme.XL, 80); - - TextView prompt = LineTheme.text(context, "›_", LineTheme.FONT_XL, LineTheme.ACCENT, Typeface.NORMAL); - prompt.setTypeface(Typeface.MONOSPACE); - box.addView(prompt, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - )); - - TextView title = LineTheme.text(context, context.getString(R.string.message_list_configure_title), LineTheme.FONT_TITLE, LineTheme.TEXT, Typeface.BOLD); - LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - titleParams.topMargin = LineTheme.dp(context, LineTheme.MD); - box.addView(title, titleParams); - - TextView desc = LineTheme.text(context, - context.getString(R.string.message_list_configure_desc), - LineTheme.FONT_MD, - LineTheme.TEXT_SECONDARY, - Typeface.NORMAL); - desc.setGravity(Gravity.CENTER); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - descParams.topMargin = LineTheme.dp(context, LineTheme.MD); - box.addView(desc, descParams); - - box.addView(actionRow(context, listener), - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + box.setGravity(Gravity.START); + LineTheme.padding(box, 28, 96, 28, 64); + TextView title = LineTheme.text(context, context.getString(R.string.chat_empty_title), 28, LineTheme.TEXT, Typeface.NORMAL); + box.addView(title); + TextView desc = LineTheme.text(context, context.getString(configure + ? R.string.message_list_configure_desc : R.string.chat_empty_message), 15, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + desc.setLineSpacing(LineTheme.dp(context, 6), 1); + LinearLayout.LayoutParams description = new LinearLayout.LayoutParams(-1, -2); + description.topMargin = LineTheme.dp(context, 20); + box.addView(desc, description); + if (configure) { + TextView addModel = actionButton(context, context.getString(R.string.empty_state_add_model), true); + addModel.setMinHeight(LineTheme.dp(context, 48)); + addModel.setOnClickListener(v -> { if (listener != null) listener.onAddModel(); }); + LinearLayout.LayoutParams button = new LinearLayout.LayoutParams(-2, -2); + button.topMargin = LineTheme.dp(context, 28); box.addView(addModel, button); + } return box; } @@ -463,6 +448,9 @@ private static final class MessageAdapter extends BaseAdapter { private final ArrayList visibleMessages = new ArrayList<>(); private final LinkedHashMap rowCache = new LinkedHashMap<>(32, 0.75f, true); private boolean showConfigureState; + private boolean generating; + private List timeline = java.util.Collections.emptyList(); + private final Map disclosure = new HashMap<>(); private boolean thinkingAutoExpand; private boolean thinkingScroll; private boolean codeWrapEnabled; @@ -514,7 +502,8 @@ boolean render(ChatUiState state) { String nextProjectPath = state == null ? "" : state.getProjectPath(); boolean conversationChanged = !stringEquals(conversationId, nextConversationId); - if (showConfigureState == nextShowConfigureState + if (generating == (state != null && state.isStreaming()) + && showConfigureState == nextShowConfigureState && thinkingAutoExpand == nextThinkingAutoExpand && thinkingScroll == nextThinkingScroll && codeWrapEnabled == nextCodeWrapEnabled @@ -526,9 +515,12 @@ && sameMessages(nextMessages)) { if (conversationChanged) { rowCache.clear(); + disclosure.clear(); } visibleMessages.clear(); visibleMessages.addAll(nextMessages); + timeline = ConversationTimeline.build(visibleMessages); + generating = state != null && state.isStreaming(); showConfigureState = nextShowConfigureState; thinkingAutoExpand = nextThinkingAutoExpand; thinkingScroll = nextThinkingScroll; @@ -545,23 +537,23 @@ public int getCount() { if (showConfigureState) { return 1; } - return visibleMessages.size(); + return visibleMessages.isEmpty() ? 1 : multiSelectMode ? visibleMessages.size() : timeline.size(); } @Override public Object getItem(int position) { - if (showConfigureState) { + if (visibleMessages.isEmpty()) { return null; } - return visibleMessages.get(position); + return messageAt(position); } @Override public long getItemId(int position) { - if (showConfigureState) { + if (visibleMessages.isEmpty()) { return -1L; } - String id = visibleMessages.get(position).getId(); + String id = messageAt(position).getId(); return id == null ? position : id.hashCode(); } @@ -572,15 +564,20 @@ public boolean hasStableIds() { @Override public int getViewTypeCount() { - return 4; + return 5; + } + + private ChatMessage messageAt(int position) { + return multiSelectMode ? visibleMessages.get(position) : timeline.get(position).first; } @Override public int getItemViewType(int position) { - if (showConfigureState) { + if (visibleMessages.isEmpty()) { return VIEW_TYPE_CONFIGURE; } - ChatMessage message = visibleMessages.get(position); + ChatMessage message = messageAt(position); + if (!multiSelectMode && timeline.get(position).isTurn) return 4; if (message.isModelSwitchNotification()) { return VIEW_TYPE_NOTICE; } @@ -589,17 +586,22 @@ public int getItemViewType(int position) { @Override public View getView(int position, View convertView, android.view.ViewGroup parent) { - if (showConfigureState) { - return convertView == null - ? createConfigureState(context, emptyStateListener) - : convertView; + if (visibleMessages.isEmpty()) { + return createConfigureState(context, emptyStateListener, showConfigureState); + } + ChatMessage message = messageAt(position); + + if (!multiSelectMode && timeline.get(position).isTurn) { + String key = cacheKey(message); + AssistantTurnView view = convertView instanceof AssistantTurnView + ? (AssistantTurnView) convertView + : obtain(AssistantTurnView.class, key, new AssistantTurnView(context)); + view.bind(timeline.get(position), disclosure, projectPath, toolReviewListener, + markdownLinkHandler, messageActionListener, codeWrapEnabled, + generating && position == timeline.size() - 1); + return view; } - ChatMessage message = visibleMessages.get(position); - if (message.isModelSwitchNotification()) { - if (convertView != null) { - return convertView; - } String ck = cacheKey(message); View cached = rowCache.get(ck); if (cached != null && cached.getParent() == null) { @@ -658,9 +660,8 @@ private void applyMultiSelectStyle(View view, ChatMessage message) { } if (multiSelectMode && selectedMessageIds.contains(message.getId())) { view.setBackground(LineTheme.roundedStroke( - context, LineTheme.ACCENT, 12, LineTheme.ACCENT)); - view.setPadding(LineTheme.dp(context, 4), LineTheme.dp(context, 4), - LineTheme.dp(context, 4), LineTheme.dp(context, 4)); + context, LineTheme.ACCENT_MUTED, 12, LineTheme.BORDER_LIGHT)); + restoreMessagePadding(view); } else { view.setBackground(null); restoreMessagePadding(view); @@ -779,9 +780,12 @@ && stringEquals(a.getId(), b.getId()) && stringEquals(a.getContent(), b.getContent()) && stringEquals(a.getReasoningContent(), b.getReasoningContent()) && a.isStreaming() == b.isStreaming() + && a.isError() == b.isError() && a.isHidden() == b.isHidden() && stringEquals(a.getCompactStatus(), b.getCompactStatus()) && stringEquals(a.getModelSwitchNotification(), b.getModelSwitchNotification()) + && a.getProcessingStartedAt() == b.getProcessingStartedAt() + && a.getProcessingFinishedAt() == b.getProcessingFinishedAt() && sameAttachments(a, b) && sameToolCalls(a, b) && sameToolResults(a, b)); diff --git a/app/src/main/java/cn/lineai/ui/component/ComposerView.java b/app/src/main/java/cn/lineai/ui/component/ComposerView.java index 192d425a..3ae1de72 100644 --- a/app/src/main/java/cn/lineai/ui/component/ComposerView.java +++ b/app/src/main/java/cn/lineai/ui/component/ComposerView.java @@ -49,6 +49,11 @@ void onSendWithImage(String text, List attachments, void onModeChanged(String mode); + default void onPermissionClick() { } + default void onProjectClick() { } + default void onSettingsClick() { } + default void onMoreClick() { } + void onStop(); void onModelQuickSwitch(String modelId); @@ -123,7 +128,7 @@ public ComposerView(Context context) { setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); setWillNotDraw(false); - LineTheme.padding(this, LineTheme.LG, LineTheme.SM, LineTheme.LG, LineTheme.LG); + LineTheme.padding(this, 20, 14, 20, 20); buildQuotePreview(); @@ -141,12 +146,13 @@ public ComposerView(Context context) { LinearLayout panel = new LinearLayout(context); panel.setOrientation(VERTICAL); - panel.setMinimumHeight(LineTheme.dp(context, 148)); - panel.setBackground(LineTheme.roundedStroke(context, LineTheme.INPUT_BG, 22, LineTheme.BORDER)); + panel.setMinimumHeight(LineTheme.dp(context, 56)); + panel.setBackground(LineTheme.rounded(context, LineTheme.INPUT_BG, 20)); addView(panel, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout metaRow = new LinearLayout(context); metaRow.setOrientation(HORIZONTAL); + metaRow.setVisibility(GONE); metaRow.setGravity(Gravity.CENTER_VERTICAL); LineTheme.padding(metaRow, LineTheme.LG, 0, LineTheme.LG, 0); panel.addView(metaRow, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 34))); @@ -185,13 +191,14 @@ public ComposerView(Context context) { android.view.View divider = new android.view.View(context); divider.setBackgroundColor(LineTheme.BORDER_LIGHT); + divider.setVisibility(GONE); panel.addView(divider, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 1)); // Quote block (hidden by default) quoteBlock = new LinearLayout(context); quoteBlock.setOrientation(HORIZONTAL); quoteBlock.setGravity(Gravity.CENTER_VERTICAL); - quoteBlock.setBackgroundColor(0xFF1E1E2E); + quoteBlock.setBackgroundColor(LineTheme.SURFACE); LineTheme.padding(quoteBlock, LineTheme.MD, LineTheme.SM, LineTheme.SM, LineTheme.SM); quoteBlock.setVisibility(GONE); android.view.View quoteBar = new android.view.View(context); @@ -218,14 +225,15 @@ public ComposerView(Context context) { LinearLayout inputRow = new LinearLayout(context); inputRow.setOrientation(HORIZONTAL); - inputRow.setGravity(Gravity.TOP); - LineTheme.padding(inputRow, LineTheme.SM, LineTheme.SM, LineTheme.SM, 0); + inputRow.setGravity(Gravity.BOTTOM); + LineTheme.padding(inputRow, 8, 6, 8, 6); panel.addView(inputRow, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); attachButton = new IconButtonView(context, IconButtonView.PLUS); attachButton.setIconColor(LineTheme.TEXT_SECONDARY); - attachButton.setIconSizeDp(40, 22); - attachButton.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 20)); + attachButton.setIconSizeDp(44, 20); + attachButton.setBackgroundColor(android.graphics.Color.TRANSPARENT); + attachButton.setContentDescription(context.getString(R.string.composer_image_button_desc)); attachButton.setOnClickListener(v -> { if (!streaming && listener != null) { listener.onAttachClick(); @@ -247,19 +255,18 @@ public ComposerView(Context context) { input.setTextColor(LineTheme.TEXT); input.setHintTextColor(LineTheme.TEXT_TERTIARY); input.setHint(context.getString(R.string.composer_hint_default)); - input.setTextSize(LineTheme.FONT_MD); + input.setTextSize(14); input.setSingleLine(false); - input.setMinLines(2); - input.setMaxLines(6); - input.setMinHeight(LineTheme.dp(context, 68)); - input.setMaxHeight(LineTheme.dp(context, 152)); - input.setGravity(Gravity.TOP | Gravity.START); + input.setMinLines(1); + input.setMaxLines(3); + input.setMinHeight(LineTheme.dp(context, 44)); + input.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); input.setImeOptions(EditorInfo.IME_ACTION_SEND); input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); input.setBackgroundColor(android.graphics.Color.TRANSPARENT); input.setIncludeFontPadding(false); - input.setPadding(LineTheme.dp(context, LineTheme.SM), LineTheme.dp(context, LineTheme.SM), - LineTheme.dp(context, LineTheme.SM), LineTheme.dp(context, LineTheme.SM)); + input.setPadding(LineTheme.dp(context, 3), LineTheme.dp(context, 10), + LineTheme.dp(context, 3), LineTheme.dp(context, 10)); input.setOnEditorActionListener((view, actionId, event) -> { if (!InputSettings.ENTER_SEND.equals(enterKeyBehavior)) { return false; @@ -294,6 +301,7 @@ public ComposerView(Context context) { } }); LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); + inputRow.addView(attachButton, new LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44))); inputRow.addView(input, inputParams); sendButton = new IconButtonView(context, IconButtonView.ARROW_UP); @@ -315,13 +323,14 @@ public ComposerView(Context context) { LinearLayout modeRow = new LinearLayout(context); modeRow.setOrientation(HORIZONTAL); + modeRow.setVisibility(GONE); modeRow.setGravity(Gravity.CENTER_VERTICAL); LineTheme.padding(modeRow, LineTheme.SM, 0, LineTheme.SM, LineTheme.SM); panel.addView(modeRow, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout.LayoutParams attachParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 40)); attachParams.rightMargin = LineTheme.dp(context, LineTheme.SM); - modeRow.addView(attachButton, attachParams); + LinearLayout.LayoutParams imageButtonParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 40)); imageButtonParams.rightMargin = LineTheme.dp(context, LineTheme.SM); @@ -352,7 +361,7 @@ public ComposerView(Context context) { LinearLayout.LayoutParams sendParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 40)); sendParams.leftMargin = LineTheme.dp(context, LineTheme.SM); - modeRow.addView(sendButton, sendParams); + inputRow.addView(sendButton, new LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44))); input.addTextChangedListener(new TextWatcher() { @Override @@ -590,6 +599,7 @@ public void render(ChatUiState state) { modelPopup.dismiss(); } if (streaming) { + if (contextDialog != null) contextDialog.dismiss(); dismissSlashPopup(); } else { updateSlashPopup(); @@ -607,44 +617,58 @@ public void render(ChatUiState state) { updateSendButton(); } - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - borderPaint.setColor(LineTheme.BORDER); - borderPaint.setStrokeWidth(1f); - canvas.drawLine(0, 0, getWidth(), 0, borderPaint); - } - private void updateSendButton() { boolean hasContent = canSend(); - if (streaming) { - if (!pendingQueue.isEmpty() && !hasContent) { - // 有队列 + 输入框空:红色停止按钮(按=停止AI并发送队列) - sendButton.setIconType(IconButtonView.STOP); - sendButton.setIconColor(LineTheme.TEXT_ON_COLOR); - sendButton.setIconSizeDp(40, 18); - sendButton.setBackground(LineTheme.rounded(getContext(), 0xFFFF8800, 20)); - } else if (hasContent) { - // 有内容:橙色箭头(按=追加排队) - sendButton.setIconType(IconButtonView.ARROW_UP); - sendButton.setIconColor(LineTheme.TEXT_ON_COLOR); - sendButton.setIconSizeDp(40, 22); - sendButton.setBackground(LineTheme.rounded(getContext(), 0xFFFFAA33, 20)); - } else { - // 无内容无队列:红色停止 - sendButton.setIconType(IconButtonView.STOP); - sendButton.setIconColor(LineTheme.TEXT_ON_COLOR); - sendButton.setIconSizeDp(40, 18); - sendButton.setBackground(LineTheme.rounded(getContext(), LineTheme.DANGER, 20)); - } - } else { - sendButton.setIconType(IconButtonView.ARROW_UP); - sendButton.setIconColor(hasContent ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_TERTIARY); - sendButton.setIconSizeDp(40, 22); - sendButton.setBackground(LineTheme.rounded(getContext(), hasContent ? LineTheme.ACCENT : LineTheme.SURFACE_LIGHT, 20)); - } + sendButton.setIconType(streaming && !hasContent ? IconButtonView.STOP : IconButtonView.ARROW_UP); + sendButton.setIconSizeDp(44, streaming && !hasContent ? 17 : 20); + sendButton.setIconColor(streaming || hasContent ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_SECONDARY); + sendButton.setBackground(LineTheme.rounded(getContext(), streaming || hasContent + ? LineTheme.ACCENT : android.graphics.Color.TRANSPARENT, 22)); + sendButton.setContentDescription(getContext().getString(streaming && !hasContent + ? R.string.chat_stop : R.string.chat_send)); sendButton.setEnabled(streaming || hasContent); - sendButton.setAlpha(sendButton.isEnabled() ? 1f : 0.72f); + sendButton.setAlpha(1f); + } + + private android.app.Dialog contextDialog; + + private void showConversationMenu() { + if (contextDialog != null) contextDialog.dismiss(); + contextDialog = new android.app.Dialog(getContext()); + LinearLayout content = new LinearLayout(getContext()); + content.setOrientation(VERTICAL); + content.setBackground(LineTheme.roundedTop(getContext(), LineTheme.BG, 24)); + LineTheme.padding(content, 28, 24, 28, 28); + TextView title = LineTheme.textMedium(getContext(), getContext().getString(R.string.chat_context_title), 22, LineTheme.TEXT); + content.addView(title, new LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(getContext(), 52))); + addContextOption(content, getContext().getString(R.string.chat_context_files), () -> listener.onAttachClick()); + addContextOption(content, getContext().getString(R.string.composer_image_button_desc), () -> listener.onImagePickerClick()); + addContextOption(content, modelText.getText().toString(), () -> showModelPopup(attachButton)); + addContextOption(content, getContext().getString(R.string.chat_context_mode, modeLabel(chatMode)), () -> showModePopup(attachButton)); + addContextOption(content, getContext().getString(R.string.chat_context_workspace), () -> listener.onProjectClick()); + addContextOption(content, getContext().getString(R.string.header_permission_desc), () -> listener.onPermissionClick()); + addContextOption(content, getContext().getString(R.string.chat_context_settings), () -> listener.onSettingsClick()); + addContextOption(content, getContext().getString(R.string.chat_context_more), () -> listener.onMoreClick()); + DialogBuilder.showBottomSheet(contextDialog, content); + } + + private void addContextOption(LinearLayout parent, String label, Runnable action) { + TextView row = LineTheme.text(getContext(), label, 15, LineTheme.TEXT, Typeface.NORMAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(LineTheme.dp(getContext(), 52)); + row.setClickable(true); + row.setFocusable(true); + row.setOnClickListener(v -> { if (contextDialog != null) contextDialog.dismiss(); if (listener != null) action.run(); }); + parent.addView(row, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + @Override + protected void onDetachedFromWindow() { + if (contextDialog != null) contextDialog.dismiss(); + if (modePopup != null) modePopup.dismiss(); + if (modelPopup != null) modelPopup.dismiss(); + dismissSlashPopup(); + super.onDetachedFromWindow(); } private boolean canSend() { @@ -760,11 +784,11 @@ private void updatePendingBlock() { LinearLayout row = new LinearLayout(ctx); row.setOrientation(HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); - row.setBackgroundColor(0xFF252536); + row.setBackgroundColor(LineTheme.INPUT_BG); LineTheme.padding(row, LineTheme.MD, 4, LineTheme.SM, 4); // 左侧橙色竖条 android.view.View bar = new android.view.View(ctx); - bar.setBackgroundColor(0xFFFFAA33); + bar.setBackgroundColor(LineTheme.WARNING); row.addView(bar, new LinearLayout.LayoutParams(LineTheme.dp(ctx, 3), LineTheme.dp(ctx, 20))); // 序号 + 预览文字 String preview = (i + 1) + ". " + (item.text.length() > 30 ? item.text.substring(0, 30) + "..." : item.text); @@ -790,7 +814,7 @@ private void updatePendingBlock() { } // 超过4条时显示折叠提示 if (pendingQueue.size() > 4) { - TextView more = LineTheme.text(ctx, " ... 还有 " + (pendingQueue.size() - 4) + " 条排队中", LineTheme.FONT_XS, 0xFFCC8800, Typeface.ITALIC); + TextView more = LineTheme.text(ctx, getContext().getString(R.string.common_more_queued, pendingQueue.size() - 4), LineTheme.FONT_XS, 0xFFCC8800, Typeface.ITALIC); LineTheme.padding(more, LineTheme.MD, 2, 0, 4); pendingContainer.addView(more, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } @@ -876,6 +900,7 @@ private void updateSlashPopup() { return; } if (streaming) { + if (contextDialog != null) contextDialog.dismiss(); dismissSlashPopup(); return; } @@ -1167,164 +1192,47 @@ private void updateModelSelector() { } private PopupWindow modelSubPopup; - + private void showModelPopup(View anchor) { if (streaming) return; - dismissSlashPopup(); - input.clearFocus(); - if (modelPopup != null && modelPopup.isShowing()) { modelPopup.dismiss(); return; } - Context ctx = getContext(); - int rowHeight = LineTheme.dp(ctx, 40); - int manageRowHeight = LineTheme.dp(ctx, 36); - int popupWidth = LineTheme.dp(ctx, 140); - - // Deduplicate sources by providerLabel - java.util.LinkedHashMap sources = new java.util.LinkedHashMap<>(); - java.util.LinkedHashMap sourceFirstModel = new java.util.LinkedHashMap<>(); - for (ModelConfig m : availableModels) { - String key = m.getProviderLabel().length() > 0 ? m.getProviderLabel() : "Other"; - if (!sources.containsKey(key)) { - sources.put(key, m.getBaseUrl()); - sourceFirstModel.put(key, m); - } - } - - if (sources.isEmpty()) { - // No models configured, just open manage - if (listener != null) listener.onModelManageClick(); - return; - } - - // Build source list popup - LinearLayout content = new LinearLayout(ctx); - content.setOrientation(VERTICAL); - content.setBackground(LineTheme.roundedStroke(ctx, LineTheme.INPUT_BG, 12, LineTheme.BORDER_LIGHT)); - LineTheme.padding(content, 4, 4, 4, 4); - - java.util.List sourceNames = new java.util.ArrayList<>(sources.keySet()); - for (String sName : sourceNames) { - // Find current model for this source - String currentModelName = ""; - for (ModelConfig m : availableModels) { - String pk = m.getProviderLabel().length() > 0 ? m.getProviderLabel() : "Other"; - if (pk.equals(sName) && m.getId().equals(selectedModelId)) { - currentModelName = m.getName().length() > 0 ? m.getName() : m.getModelId(); - break; - } + dismissSlashPopup(); input.clearFocus(); + if (availableModels.isEmpty()) { if (listener != null) listener.onModelManageClick(); return; } + if (contextDialog != null) contextDialog.dismiss(); + contextDialog = DialogBuilder.create(getContext()); + LinearLayout panel = choicePanel(getContext().getString(R.string.screen_models_title)); + java.util.LinkedHashMap> sources = new java.util.LinkedHashMap<>(); + for (ModelConfig model : availableModels) { + String source = model.getProviderLabel().isEmpty() ? model.getProtocolType().getLabel() : model.getProviderLabel(); + sources.computeIfAbsent(source, key -> new java.util.ArrayList<>()).add(model); + } + for (java.util.Map.Entry> source : sources.entrySet()) { + TextView sourceTitle = LineTheme.textMedium(getContext(), source.getKey(), 14, LineTheme.TEXT_SECONDARY); + LineTheme.padding(sourceTitle, 0, 24, 0, 8); panel.addView(sourceTitle); + for (ModelConfig model : source.getValue()) { + String name = model.getName().isEmpty() ? model.getModelId() : model.getName(); + panel.addView(new OptionRowView(getContext(), IconButtonView.BOX, name, model.getModelId(), + model.getId().equals(selectedModelId), () -> { + contextDialog.dismiss(); + if (listener != null && !model.getId().equals(selectedModelId)) listener.onModelQuickSwitch(model.getId()); + }), new LayoutParams(-1, -2)); } - - LinearLayout row = new LinearLayout(ctx); - row.setOrientation(HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - boolean isActive = currentModelName.length() > 0; - row.setBackground(LineTheme.rounded(ctx, isActive ? 0xFF1A2A1A : android.graphics.Color.TRANSPARENT, 8)); - LineTheme.padding(row, LineTheme.SM, 0, LineTheme.SM, 0); - row.setClickable(true); - - TextView nameView = LineTheme.textMedium(ctx, sName, LineTheme.FONT_SM, isActive ? LineTheme.ACCENT : LineTheme.TEXT); - nameView.setSingleLine(true); - row.addView(nameView, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - - // Small arrow indicating submenu - TextView arrow = LineTheme.text(ctx, "\u203A", LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - row.addView(arrow, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - - final String sourceName = sName; - row.setOnClickListener(v -> showModelSubMenu(v, sourceName, sources.get(sourceName))); - content.addView(row, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); - } - - // Manage button - View div = new View(ctx); - div.setBackgroundColor(LineTheme.BORDER_LIGHT); - content.addView(div, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 1)); - TextView manageItem = LineTheme.textMedium(ctx, "\u2699 \u7ba1\u7406\u6a21\u578b...", LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY); - manageItem.setGravity(Gravity.CENTER_VERTICAL); - manageItem.setPadding(LineTheme.dp(ctx, LineTheme.SM), 0, 0, 0); - manageItem.setClickable(true); - manageItem.setOnClickListener(v -> { - if (modelPopup != null) modelPopup.dismiss(); - post(() -> { if (listener != null) listener.onModelManageClick(); }); - }); - content.addView(manageItem, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, manageRowHeight)); - - int popupHeight = rowHeight * sourceNames.size() + manageRowHeight + LineTheme.dp(ctx, 12); - modelPopup = new PopupWindow(content, popupWidth, popupHeight, true); - modelPopup.setOutsideTouchable(true); - modelPopup.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); - modelPopup.setOnDismissListener(() -> { if (modelSubPopup != null && modelSubPopup.isShowing()) modelSubPopup.dismiss(); }); - int[] location = new int[2]; - anchor.getLocationOnScreen(location); - int screenWidth = ctx.getResources().getDisplayMetrics().widthPixels; - int centeredX = location[0] + (anchor.getWidth() - popupWidth) / 2; - int popupX = Math.max(LineTheme.dp(ctx, LineTheme.SM), Math.min(centeredX, screenWidth - popupWidth - LineTheme.dp(ctx, LineTheme.SM))); - modelPopup.showAtLocation(this, Gravity.NO_GRAVITY, popupX, Math.max(0, location[1] - popupHeight - LineTheme.dp(ctx, 8))); - } - - private void showModelSubMenu(View sourceRow, String sourceName, String baseUrl) { - if (modelSubPopup != null && modelSubPopup.isShowing()) modelSubPopup.dismiss(); - Context ctx = getContext(); - int rowHeight = LineTheme.dp(ctx, 36); - int subWidth = LineTheme.dp(ctx, 160); - - // Collect models for this source - java.util.List models = new java.util.ArrayList<>(); - for (ModelConfig m : availableModels) { - String pk = m.getProviderLabel().length() > 0 ? m.getProviderLabel() : "Other"; - if (pk.equals(sourceName)) models.add(m); - } - - LinearLayout sub = new LinearLayout(ctx); - sub.setOrientation(VERTICAL); - sub.setBackground(LineTheme.roundedStroke(ctx, LineTheme.INPUT_BG, 10, LineTheme.BORDER_LIGHT)); - LineTheme.padding(sub, 4, 4, 4, 4); - - // Query button - TextView queryBtn = LineTheme.textMedium(ctx, ctx.getString(R.string.composer_model_submenu_query_button), LineTheme.FONT_XS, LineTheme.ACCENT); - queryBtn.setGravity(Gravity.CENTER); - queryBtn.setBackground(LineTheme.roundedStroke(ctx, LineTheme.SURFACE_LIGHT, 6, LineTheme.ACCENT)); - LineTheme.padding(queryBtn, 0, 3, 0, 3); - queryBtn.setClickable(true); - queryBtn.setOnClickListener(v -> { - queryBtn.setText(R.string.screen_model_add_query_button_loading); - queryModelCount(baseUrl, queryBtn, ctx); - }); - sub.addView(queryBtn, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(ctx, 28))); - - // Model items - for (ModelConfig m : models) { - boolean sel = m.getId().equals(selectedModelId); - TextView item = LineTheme.textMedium(ctx, m.getName().length() > 0 ? m.getName() : m.getModelId(), LineTheme.FONT_SM, sel ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT); - item.setSingleLine(true); - item.setEllipsize(TextUtils.TruncateAt.END); - item.setGravity(Gravity.CENTER_VERTICAL); - item.setBackground(LineTheme.rounded(ctx, sel ? LineTheme.ACCENT : android.graphics.Color.TRANSPARENT, 8)); - LineTheme.padding(item, LineTheme.SM, 0, LineTheme.SM, 0); - item.setClickable(true); - final String mid = m.getId(); - item.setOnClickListener(v2 -> { - if (modelSubPopup != null) modelSubPopup.dismiss(); - if (modelPopup != null) modelPopup.dismiss(); - post(() -> { if (listener != null && !mid.equals(selectedModelId)) listener.onModelQuickSwitch(mid); }); + TextView query = LineTheme.text(getContext(), getContext().getString(R.string.composer_model_submenu_query_button), 14, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + query.setMinimumHeight(LineTheme.dp(getContext(),48)); query.setGravity(Gravity.CENTER_VERTICAL); + query.setOnClickListener(v -> { + query.setText(R.string.screen_model_add_query_button_loading); + queryModelCount(source.getValue().get(0).getBaseUrl(), query, getContext()); }); - sub.addView(item, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); - } - - int subHeight = LineTheme.dp(ctx, 28) + rowHeight * Math.min(models.size(), 8) + LineTheme.dp(ctx, 8); - if (subHeight > LineTheme.dp(ctx, 320)) subHeight = LineTheme.dp(ctx, 320); - modelSubPopup = new PopupWindow(sub, subWidth, subHeight, false); - modelSubPopup.setOutsideTouchable(true); - modelSubPopup.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); - - // Position to the right of the source row - int[] loc = new int[2]; - sourceRow.getLocationOnScreen(loc); - int subX = loc[0] + sourceRow.getWidth() + LineTheme.dp(ctx, 4); - int screenW = ctx.getResources().getDisplayMetrics().widthPixels; - if (subX + subWidth > screenW - LineTheme.dp(ctx, 8)) { - subX = loc[0] - subWidth - LineTheme.dp(ctx, 4); + panel.addView(query, new LayoutParams(-1, -2)); } - modelSubPopup.showAtLocation(this, Gravity.NO_GRAVITY, subX, loc[1]); + addContextOption(panel, getContext().getString(R.string.screen_models_title), () -> listener.onModelManageClick()); + DialogBuilder.showBottomSheet(contextDialog, panel); + } + + private LinearLayout choicePanel(String title) { + LinearLayout panel = new LinearLayout(getContext()); panel.setOrientation(VERTICAL); + LineTheme.padding(panel, 24, 24, 24, 24); + TextView heading = LineTheme.textMedium(getContext(), title, 22, LineTheme.TEXT); + panel.addView(heading, new LayoutParams(-1, -2)); return panel; } private void queryModelCount(String baseUrl, TextView queryBtn, Context ctx) { @@ -1343,90 +1251,20 @@ private void queryModelCount(String baseUrl, TextView queryBtn, Context ctx) { }, "linecode-model-query").start(); } - private LinearLayout modelOptionRow(Context ctx, ModelConfig model, boolean selected) { - LinearLayout row = new LinearLayout(ctx); - row.setOrientation(HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setPadding(LineTheme.dp(ctx, LineTheme.MD), 0, LineTheme.dp(ctx, LineTheme.MD), 0); - row.setBackground(LineTheme.rounded(ctx, selected ? LineTheme.ACCENT : android.graphics.Color.TRANSPARENT, 11)); - row.setClickable(true); - row.setOnClickListener(v -> { - if (modelPopup != null) modelPopup.dismiss(); - final String mid = model.getId(); - post(() -> { - if (!mid.equals(selectedModelId) && listener != null) { - listener.onModelQuickSwitch(mid); - } - }); - }); - View dot = new View(ctx); - dot.setBackground(LineTheme.rounded(ctx, selected ? LineTheme.TEXT_ON_COLOR : LineTheme.BORDER, 4)); - LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(LineTheme.dp(ctx, 7), LineTheme.dp(ctx, 7)); - dotParams.rightMargin = LineTheme.dp(ctx, LineTheme.SM); - row.addView(dot, dotParams); - String displayName = model.getName().length() > 0 ? model.getName() : model.getModelId(); - TextView name = LineTheme.textMedium(ctx, displayName, LineTheme.FONT_SM, selected ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_SECONDARY); - name.setSingleLine(true); - name.setEllipsize(TextUtils.TruncateAt.END); - row.addView(name, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - TextView provider = LineTheme.text(ctx, model.getProviderLabel(), LineTheme.FONT_XS, selected ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - LinearLayout.LayoutParams pp = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - pp.leftMargin = LineTheme.dp(ctx, LineTheme.SM); - row.addView(provider, pp); - return row; - } - private void showModePopup(View anchor) { - if (streaming) { - return; - } + if (streaming) return; dismissSlashPopup(); - if (modePopup != null && modePopup.isShowing()) { - modePopup.dismiss(); - return; - } - Context context = getContext(); - int popupWidth = LineTheme.dp(context, 112); - int rowHeight = LineTheme.dp(context, 38); - int popupHeight = rowHeight * 4 + LineTheme.dp(context, 6); - LinearLayout content = new LinearLayout(context); - content.setOrientation(VERTICAL); - content.setBackground(LineTheme.roundedStroke(context, LineTheme.INPUT_BG, 14, LineTheme.BORDER_LIGHT)); - LineTheme.padding(content, 3, 3, 3, 3); - content.addView(modeOption(context, "Chat", ChatMode.CHAT), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); - content.addView(modeOption(context, "Plan", ChatMode.PLAN), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); - content.addView(modeOption(context, "Agent", ChatMode.AGENT), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); - content.addView(modeOption(context, "\u63a7\u5236", ChatMode.CONTROL), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); - modePopup = new PopupWindow(content, popupWidth, popupHeight, true); - modePopup.setOutsideTouchable(true); - modePopup.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); - int[] location = new int[2]; - anchor.getLocationOnScreen(location); - int screenWidth = context.getResources().getDisplayMetrics().widthPixels; - int centeredX = location[0] + (anchor.getWidth() - popupWidth) / 2; - int popupX = Math.max(LineTheme.dp(context, LineTheme.SM), - Math.min(centeredX, screenWidth - popupWidth - LineTheme.dp(context, LineTheme.SM))); - modePopup.showAtLocation(this, Gravity.NO_GRAVITY, popupX, Math.max(0, location[1] - popupHeight - LineTheme.dp(context, 8))); - } - - private TextView modeOption(Context context, String label, String mode) { - boolean selected = mode.equals(chatMode); - TextView item = LineTheme.textMedium(context, label, LineTheme.FONT_SM, - selected ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_SECONDARY); - item.setGravity(Gravity.CENTER_VERTICAL); - item.setSingleLine(true); - item.setPadding(LineTheme.dp(context, LineTheme.MD), 0, LineTheme.dp(context, LineTheme.MD), 0); - item.setBackground(LineTheme.rounded(context, selected ? LineTheme.ACCENT : android.graphics.Color.TRANSPARENT, 11)); - item.setClickable(true); - item.setOnClickListener(v -> { - if (modePopup != null) { - modePopup.dismiss(); - } - if (!mode.equals(chatMode) && listener != null) { - listener.onModeChanged(mode); - } - }); - return item; + if (contextDialog != null) contextDialog.dismiss(); + contextDialog = DialogBuilder.create(getContext()); + LinearLayout panel = choicePanel(getContext().getString(R.string.chat_context_mode, modeLabel(chatMode))); + for (String mode : new String[] {ChatMode.CHAT, ChatMode.PLAN, ChatMode.AGENT, ChatMode.CONTROL}) { + panel.addView(new OptionRowView(getContext(), IconButtonView.MESSAGE_SQUARE, modeLabel(mode), null, + mode.equals(chatMode), () -> { + contextDialog.dismiss(); + if (listener != null && !mode.equals(chatMode)) listener.onModeChanged(mode); + }), new LayoutParams(-1, -2)); + } + DialogBuilder.showBottomSheet(contextDialog, panel); } private String modeLabel(String mode) { diff --git a/app/src/main/java/cn/lineai/ui/component/ContextCompactBlockView.java b/app/src/main/java/cn/lineai/ui/component/ContextCompactBlockView.java index bc81887f..6a2af13f 100644 --- a/app/src/main/java/cn/lineai/ui/component/ContextCompactBlockView.java +++ b/app/src/main/java/cn/lineai/ui/component/ContextCompactBlockView.java @@ -22,9 +22,9 @@ public ContextCompactBlockView(Context context) { super(context); setOrientation(HORIZONTAL); setGravity(Gravity.CENTER_VERTICAL); - setMinimumHeight(LineTheme.dp(context, 34)); - setBackground(LineTheme.rounded(context, LineTheme.CODE_BG, 6)); - LineTheme.padding(this, LineTheme.MD, LineTheme.XS, LineTheme.MD, LineTheme.XS); + setMinimumHeight(LineTheme.dp(context, 48)); + + LineTheme.padding(this, 28, 12, 28, 12); icon = new IconButtonView(context, IconButtonView.ARCHIVE); icon.setClickable(false); @@ -32,7 +32,7 @@ public ContextCompactBlockView(Context context) { addView(icon, new LayoutParams(LineTheme.dp(context, 18), LineTheme.dp(context, 18))); label = LineTheme.text(context, context.getString(R.string.context_compact_label), LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - label.setTypeface(Typeface.MONOSPACE); + LayoutParams labelParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); labelParams.leftMargin = LineTheme.dp(context, 6); addView(label, labelParams); diff --git a/app/src/main/java/cn/lineai/ui/component/DialogBuilder.java b/app/src/main/java/cn/lineai/ui/component/DialogBuilder.java index 68835b4b..20325ac0 100644 --- a/app/src/main/java/cn/lineai/ui/component/DialogBuilder.java +++ b/app/src/main/java/cn/lineai/ui/component/DialogBuilder.java @@ -26,6 +26,17 @@ */ public final class DialogBuilder { + private static View boundedContent(View content) { + Context context = content.getContext(); + int maxDp = Math.round(context.getResources().getDisplayMetrics().heightPixels / context.getResources().getDisplayMetrics().density * .82f); + cn.lineai.ui.theme.BoundedScrollView scroll = new cn.lineai.ui.theme.BoundedScrollView(context, maxDp); + scroll.setVerticalScrollBarEnabled(false); + scroll.setBackground(cn.lineai.ui.theme.LineTheme.rounded(context, cn.lineai.ui.theme.LineTheme.BG, 24)); + scroll.setClipToOutline(true); + scroll.addView(content, new android.widget.ScrollView.LayoutParams(-1, -2)); + return scroll; + } + private DialogBuilder() { } @@ -53,7 +64,7 @@ public static Dialog create(Context context) { * @return the same dialog instance, now visible. */ public static Dialog showInset(Dialog dialog, View contentView) { - dialog.setContentView(contentView); + dialog.setContentView(boundedContent(contentView)); dialog.setOnShowListener(d -> { Window window = dialog.getWindow(); if (window != null) { @@ -90,14 +101,17 @@ public static Dialog showInset(Context context, View contentView) { */ public static Dialog showBottomSheet(Dialog dialog, View contentView) { dialog.setCanceledOnTouchOutside(true); - dialog.setContentView(contentView); + dialog.setContentView(boundedContent(contentView)); dialog.show(); Window window = dialog.getWindow(); if (window != null) { window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, + window.setLayout(DialogDimensions.insetDialogWidth(dialog.getContext()), ViewGroup.LayoutParams.WRAP_CONTENT); window.setGravity(Gravity.BOTTOM); + android.view.WindowManager.LayoutParams attributes = window.getAttributes(); + attributes.y = cn.lineai.ui.theme.LineTheme.dp(dialog.getContext(), 16); + window.setAttributes(attributes); } return dialog; } diff --git a/app/src/main/java/cn/lineai/ui/component/DialogDimensions.java b/app/src/main/java/cn/lineai/ui/component/DialogDimensions.java index 081a1569..ad93a3f1 100644 --- a/app/src/main/java/cn/lineai/ui/component/DialogDimensions.java +++ b/app/src/main/java/cn/lineai/ui/component/DialogDimensions.java @@ -22,6 +22,6 @@ private DialogDimensions() { */ public static int insetDialogWidth(Context context) { int width = context.getResources().getDisplayMetrics().widthPixels - LineTheme.dp(context, 32); - return Math.max(LineTheme.dp(context, 280), width); + return Math.max(1, Math.min(LineTheme.dp(context, 560), width)); } } diff --git a/app/src/main/java/cn/lineai/ui/component/DialogManager.java b/app/src/main/java/cn/lineai/ui/component/DialogManager.java index 77e04ae0..ab981056 100644 --- a/app/src/main/java/cn/lineai/ui/component/DialogManager.java +++ b/app/src/main/java/cn/lineai/ui/component/DialogManager.java @@ -62,7 +62,7 @@ public void showConfirm(Context context, String title, String message, if (context == null) { return; } - AlertDialog dialog = new AlertDialog.Builder(context) + AlertDialog dialog = new LineAlertDialog.Builder(context) .setTitle(title == null ? "" : title) .setMessage(message == null ? "" : message) .setNegativeButton(context.getString(R.string.common_cancel), (d, which) -> { @@ -97,7 +97,7 @@ public void showMessage(Context context, String title, String message) { if (context == null) { return; } - new AlertDialog.Builder(context) + new LineAlertDialog.Builder(context) .setTitle(title == null ? "" : title) .setMessage(message == null ? "" : message) .setPositiveButton(context.getString(R.string.common_confirm), null) @@ -137,7 +137,7 @@ public void showInput(Context context, String title, String message, String hint horizontalPadding, LineTheme.dp(context, LineTheme.SM)); - AlertDialog.Builder builder = new AlertDialog.Builder(context) + AlertDialog.Builder builder = new LineAlertDialog.Builder(context) .setTitle(title == null ? "" : title) .setNegativeButton(context.getString(R.string.common_cancel), null) .setPositiveButton(context.getString(R.string.common_confirm), (d, which) -> { diff --git a/app/src/main/java/cn/lineai/ui/component/DirectoryPickerSheetView.java b/app/src/main/java/cn/lineai/ui/component/DirectoryPickerSheetView.java index c2e8c43e..0d1ae865 100644 --- a/app/src/main/java/cn/lineai/ui/component/DirectoryPickerSheetView.java +++ b/app/src/main/java/cn/lineai/ui/component/DirectoryPickerSheetView.java @@ -49,17 +49,20 @@ public DirectoryPickerSheetView(Context context) { backdrop.setOnClickListener(v -> close()); addView(backdrop, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); - panel = new LinearLayout(context); + panel = new InsetSheetLayout(context); panel.setOrientation(LinearLayout.VERTICAL); - panel.setBackground(LineTheme.roundedTop(context, LineTheme.SURFACE_ELEVATED, 16)); + panel.setClipToOutline(true); + panel.setBackground(LineTheme.roundedStroke(context, LineTheme.BG, 24, LineTheme.BORDER_LIGHT)); FrameLayout.LayoutParams panelParams = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 560)); - panelParams.gravity = Gravity.BOTTOM; + panelParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + panelParams.leftMargin = panelParams.rightMargin = LineTheme.dp(context, 16); + panelParams.bottomMargin = LineTheme.dp(context, 16); addView(panel, panelParams); LinearLayout header = new LinearLayout(context); header.setOrientation(LinearLayout.HORIZONTAL); header.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(header, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); + LineTheme.padding(header, 20, 20, 20, 16); panel.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout titles = new LinearLayout(context); @@ -78,10 +81,10 @@ public DirectoryPickerSheetView(Context context) { IconButtonView close = new IconButtonView(context, IconButtonView.CLOSE); close.setIconColor(LineTheme.TEXT_SECONDARY); - close.setIconSizeDp(36, 18); - close.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 18)); + close.setIconSizeDp(48, 18); + close.setOnClickListener(v -> close()); - LinearLayout.LayoutParams closeParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36)); + LinearLayout.LayoutParams closeParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 48), LineTheme.dp(context, 48)); closeParams.leftMargin = LineTheme.dp(context, LineTheme.MD); header.addView(close, closeParams); @@ -96,7 +99,7 @@ public DirectoryPickerSheetView(Context context) { confirmButton = new IconButtonView(context, IconButtonView.CHECK); confirmButton.setIconColor(LineTheme.TEXT_ON_COLOR); confirmButton.setIconSizeDp(52, 22); - confirmButton.setBackground(LineTheme.rounded(context, LineTheme.ACCENT, 26)); + confirmButton.setBackground(LineTheme.rounded(context, LineTheme.ACCENT, 14)); confirmButton.setOnClickListener(v -> { if (listener != null) { listener.onDirectoryPickerConfirmed(); @@ -106,7 +109,16 @@ public DirectoryPickerSheetView(Context context) { confirmParams.gravity = Gravity.BOTTOM | Gravity.END; confirmParams.rightMargin = LineTheme.dp(context, LineTheme.LG); confirmParams.bottomMargin = LineTheme.dp(context, LineTheme.LG); - addView(confirmButton, confirmParams); + header.addView(confirmButton, new LinearLayout.LayoutParams(LineTheme.dp(context, 48), LineTheme.dp(context, 48))); + } + + @Override protected void onMeasure(int width, int height) { + int available = Math.max(1, MeasureSpec.getSize(height)-LineTheme.dp(getContext(),64)); + ((InsetSheetLayout)panel).setAvailableHeight(available); + android.view.ViewGroup.LayoutParams params = panel.getLayoutParams(); + int target = Math.min(LineTheme.dp(getContext(),640),available); + if (params.height != target) { params.height = target; panel.setLayoutParams(params); } + super.onMeasure(width,height); } public void setListener(Listener listener) { @@ -130,7 +142,7 @@ public void show(String title, String subtitle, FileTreeNode tree, String select ScrollView scrollView = new ScrollView(getContext()); LinearLayout treeList = new LinearLayout(getContext()); treeList.setOrientation(LinearLayout.VERTICAL); - LineTheme.padding(treeList, LineTheme.SM, LineTheme.SM, LineTheme.SM, 90); + LineTheme.padding(treeList, 12, 8, 12, 24); scrollView.addView(treeList, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); body.addView(scrollView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); addParentRow(treeList, tree); @@ -166,18 +178,13 @@ private void openAnimated() { bringToFront(); float offset = pageOffset(); panel.setTranslationX(offset); - confirmButton.setTranslationX(offset); + backdrop.setAlpha(0f); panel.animate() .translationX(0f) .setDuration(OPEN_MS) .setInterpolator(new DecelerateInterpolator()) .start(); - confirmButton.animate() - .translationX(0f) - .setDuration(OPEN_MS) - .setInterpolator(new DecelerateInterpolator()) - .start(); backdrop.animate() .alpha(1f) .setDuration(OPEN_MS) @@ -195,11 +202,6 @@ private void closeAnimated() { .setDuration(CLOSE_MS) .setInterpolator(new AccelerateInterpolator()) .start(); - confirmButton.animate() - .translationX(offset) - .setDuration(CLOSE_MS) - .setInterpolator(new AccelerateInterpolator()) - .start(); backdrop.animate() .alpha(0f) .setDuration(CLOSE_MS) @@ -260,6 +262,7 @@ private void addParentRow(LinearLayout treeList, FileTreeNode tree) { LinearLayout row = new LinearLayout(context); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(LineTheme.dp(context, 52)); row.setClickable(true); row.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 8)); row.setOnClickListener(v -> { @@ -333,6 +336,7 @@ private void addNodeRow(LinearLayout treeList, FileTreeNode node) { LinearLayout row = new LinearLayout(context); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(LineTheme.dp(context, 52)); row.setBackground(selected ? LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 8) : null); row.setClickable(node.isDirectory()); if (node.isDirectory()) { diff --git a/app/src/main/java/cn/lineai/ui/component/DisclosureSectionView.java b/app/src/main/java/cn/lineai/ui/component/DisclosureSectionView.java new file mode 100644 index 00000000..ee253672 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/DisclosureSectionView.java @@ -0,0 +1,40 @@ +package cn.lineai.ui.component; +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import cn.lineai.ui.theme.LineTheme; +import cn.lineai.ui.theme.IconButtonView; + +/** Retains live editors and their values while a section is closed. */ +public final class DisclosureSectionView extends LinearLayout { + private final LinearLayout body; + private final IconButtonView chevron; + private boolean expanded; + public DisclosureSectionView(Context context, String title, boolean open) { + super(context); setOrientation(VERTICAL); + LinearLayout header = new LinearLayout(context); header.setGravity(Gravity.CENTER_VERTICAL); + header.setMinimumHeight(LineTheme.dp(context, 52)); + header.setBackground(LineTheme.pressable(context)); + TextView label = LineTheme.textMedium(context, title, 14, LineTheme.TEXT_SECONDARY); + header.addView(label, new LayoutParams(0, -2, 1)); + chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); + chevron.setIconColor(LineTheme.TEXT_SECONDARY); chevron.setIconSizeDp(24, 16); chevron.setClickable(false); + header.addView(chevron, new LayoutParams(LineTheme.dp(context,24),LineTheme.dp(context,24))); + addView(header, new LayoutParams(-1,-2)); + body = new LinearLayout(context); body.setOrientation(VERTICAL); addView(body,new LayoutParams(-1,-2)); + header.setOnClickListener(v -> setExpanded(!expanded)); setExpanded(open); + } + public LinearLayout getBody() { return body; } + public void setExpanded(boolean open) { + expanded = open; body.setVisibility(open ? VISIBLE : GONE); chevron.setRotation(open ? 90 : 0); + } + public static DisclosureSectionView foldTail(LinearLayout parent, int from, String title, boolean open) { + DisclosureSectionView section = new DisclosureSectionView(parent.getContext(), title, open); + while (parent.getChildCount() > from) { + View child = parent.getChildAt(from); parent.removeViewAt(from); section.body.addView(child); + } + parent.addView(section,new LayoutParams(-1,-2)); return section; + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/DrawerView.java b/app/src/main/java/cn/lineai/ui/component/DrawerView.java index a2f30aee..874d93db 100644 --- a/app/src/main/java/cn/lineai/ui/component/DrawerView.java +++ b/app/src/main/java/cn/lineai/ui/component/DrawerView.java @@ -89,7 +89,7 @@ public DrawerView(Context context) { sidebar = new LinearLayout(context); sidebar.setOrientation(LinearLayout.VERTICAL); - sidebar.setBackgroundColor(LineTheme.SURFACE_ELEVATED); + sidebar.setBackgroundColor(LineTheme.BG); FrameLayout.LayoutParams sidebarParams = new FrameLayout.LayoutParams(drawerWidth(context), LayoutParams.MATCH_PARENT); sidebarParams.gravity = Gravity.START; addView(sidebar, sidebarParams); @@ -97,7 +97,7 @@ public DrawerView(Context context) { LinearLayout header = new LinearLayout(context); header.setOrientation(LinearLayout.HORIZONTAL); header.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(header, LineTheme.LG, 50, LineTheme.LG, LineTheme.MD); + LineTheme.padding(header, 24, 40, 16, 24); sidebar.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); headerTitle = LineTheme.text(context, context.getString(R.string.drawer_title_conversations), LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); @@ -110,7 +110,7 @@ public DrawerView(Context context) { tabs = new LinearLayout(context); tabs.setOrientation(LinearLayout.HORIZONTAL); - tabs.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 8)); + LineTheme.padding(tabs, 2, 2, 2, 2); LinearLayout.LayoutParams tabParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); tabParams.leftMargin = LineTheme.dp(context, LineTheme.LG); @@ -306,8 +306,9 @@ private void renderConversations() { Context context = getContext(); LinearLayout newButton = new LinearLayout(context); newButton.setOrientation(LinearLayout.HORIZONTAL); - newButton.setGravity(Gravity.CENTER); - newButton.setBackground(LineTheme.rounded(context, LineTheme.ACCENT, 12)); + newButton.setGravity(Gravity.CENTER_VERTICAL); + LineTheme.padding(newButton, 16, 12, 16, 12); + newButton.setBackground(LineTheme.rounded(context, LineTheme.INPUT_BG, 14)); newButton.setClickable(true); newButton.setOnClickListener(v -> { if (listener != null) { @@ -315,13 +316,13 @@ private void renderConversations() { } close(); }); - IconButtonView plus = inlineIcon(context, IconButtonView.PLUS, android.graphics.Color.BLACK, 18); + IconButtonView plus = inlineIcon(context, IconButtonView.PLUS, LineTheme.TEXT, 18); newButton.addView(plus, new LinearLayout.LayoutParams(LineTheme.dp(context, 18), LineTheme.dp(context, 18))); - TextView label = LineTheme.text(context, context.getString(R.string.drawer_new_conversation), LineTheme.FONT_MD, android.graphics.Color.BLACK, Typeface.BOLD); + TextView label = LineTheme.text(context, context.getString(R.string.drawer_new_conversation), LineTheme.FONT_MD, LineTheme.TEXT, Typeface.BOLD); LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); labelParams.leftMargin = LineTheme.dp(context, LineTheme.SM); newButton.addView(label, labelParams); - LinearLayout.LayoutParams newParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 45)); + LinearLayout.LayoutParams newParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 52)); newParams.leftMargin = LineTheme.dp(context, LineTheme.LG); newParams.rightMargin = LineTheme.dp(context, LineTheme.LG); newParams.bottomMargin = LineTheme.dp(context, LineTheme.MD); @@ -330,7 +331,7 @@ private void renderConversations() { ScrollView scrollView = new ScrollView(context); LinearLayout list = new LinearLayout(context); list.setOrientation(LinearLayout.VERTICAL); - LineTheme.padding(list, LineTheme.SM, 0, LineTheme.SM, 0); + LineTheme.padding(list, 12, 12, 12, 32); scrollView.addView(list, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); body.addView(scrollView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); @@ -357,7 +358,7 @@ private void renderFiles() { Context context = getContext(); LinearLayout strip = new LinearLayout(context); strip.setOrientation(LinearLayout.VERTICAL); - strip.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_LIGHT, 8, LineTheme.BORDER_LIGHT)); + LineTheme.padding(strip, LineTheme.SM, LineTheme.SM, LineTheme.SM, LineTheme.SM); if (projectRemovable) { strip.setClickable(true); @@ -371,7 +372,7 @@ private void renderFiles() { path.setEllipsize(TextUtils.TruncateAt.END); path.setHorizontallyScrolling(false); LinearLayout.LayoutParams pathParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - pathParams.topMargin = LineTheme.dp(context, 2); + pathParams.topMargin = LineTheme.dp(context, 6); strip.addView(path, pathParams); if (projectRemovable) { attachRemoveProjectLongPress(strip); @@ -403,7 +404,7 @@ private void renderFiles() { private void showRemoveProjectDialog() { Context context = getContext(); - AlertDialog dialog = new AlertDialog.Builder(context) + AlertDialog dialog = new LineAlertDialog.Builder(context) .setTitle(context.getString(R.string.drawer_project_remove_title)) .setMessage(context.getString(R.string.drawer_project_remove_message, projectLabel)) .setNegativeButton(context.getString(R.string.common_cancel), null) @@ -483,7 +484,9 @@ private void addConversationItem(LinearLayout list, String id, String title, Str LinearLayout item = new LinearLayout(context); item.setOrientation(LinearLayout.HORIZONTAL); item.setGravity(Gravity.CENTER_VERTICAL); - item.setBackground(active ? LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 8) : null); + item.setBackground(active + ? LineTheme.roundedStroke(context, LineTheme.INPUT_BG, 12, LineTheme.ACCENT) + : LineTheme.pressable(context)); item.setClickable(true); item.setOnClickListener(v -> { if (listener != null) { @@ -491,15 +494,17 @@ private void addConversationItem(LinearLayout list, String id, String title, Str } close(); }); - LineTheme.padding(item, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); - list.addView(item, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + LineTheme.padding(item, 8, 16, 4, 16); + LinearLayout.LayoutParams itemParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + itemParams.bottomMargin = LineTheme.dp(context, 8); + list.addView(item, itemParams); FrameLayout iconBox = new FrameLayout(context); iconBox.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 14)); IconButtonView messageIcon = inlineIcon(context, IconButtonView.MESSAGE_SQUARE, active ? LineTheme.ACCENT : LineTheme.TEXT_TERTIARY, 16); FrameLayout.LayoutParams messageParams = new FrameLayout.LayoutParams(LineTheme.dp(context, 16), LineTheme.dp(context, 16), Gravity.CENTER); iconBox.addView(messageIcon, messageParams); - item.addView(iconBox, new LinearLayout.LayoutParams(LineTheme.dp(context, 28), LineTheme.dp(context, 28))); + LinearLayout texts = new LinearLayout(context); texts.setOrientation(LinearLayout.VERTICAL); @@ -509,23 +514,23 @@ private void addConversationItem(LinearLayout list, String id, String title, Str item.addView(texts, textsParams); TextView titleView = active - ? LineTheme.textMedium(context, title, LineTheme.FONT_SM, LineTheme.ACCENT) - : LineTheme.text(context, title, LineTheme.FONT_SM, LineTheme.TEXT, Typeface.NORMAL); + ? LineTheme.textMedium(context, title, LineTheme.FONT_MD, LineTheme.TEXT) + : LineTheme.text(context, title, LineTheme.FONT_MD, LineTheme.TEXT, Typeface.NORMAL); titleView.setSingleLine(true); texts.addView(titleView, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); TextView timeView = LineTheme.text(context, time, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); LinearLayout.LayoutParams timeParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - timeParams.topMargin = LineTheme.dp(context, 2); + timeParams.topMargin = LineTheme.dp(context, 6); texts.addView(timeView, timeParams); - IconButtonView trash = sizedIcon(context, IconButtonView.TRASH_2, LineTheme.TEXT_TERTIARY, 22, 14); + IconButtonView trash = sizedIcon(context, IconButtonView.TRASH_2, LineTheme.TEXT_TERTIARY, 48, 16); trash.setOnClickListener(v -> { if (listener != null) { listener.onConversationDeleted(id); } }); - item.addView(trash, new LinearLayout.LayoutParams(LineTheme.dp(context, 22), LineTheme.dp(context, 22))); + item.addView(trash, new LinearLayout.LayoutParams(LineTheme.dp(context, 48), LineTheme.dp(context, 48))); } private void addFileNode(LinearLayout tree, FileTreeNode node, int depth, boolean root) { @@ -577,8 +582,9 @@ private void addFileRow( } return true; }); - int left = LineTheme.SM + depth * 16; - LineTheme.padding(row, left, 4, LineTheme.SM, 4); + int left = 16 + Math.min(depth, 5) * 16; + LineTheme.padding(row, left, 12, 16, 12); + row.setMinimumHeight(LineTheme.dp(context, 48)); tree.addView(row, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); IconButtonView icon = inlineIcon(context, iconType, iconColor, iconSize); diff --git a/app/src/main/java/cn/lineai/ui/component/ErrorLogsScreenView.java b/app/src/main/java/cn/lineai/ui/component/ErrorLogsScreenView.java index 73a2a767..38ceaa70 100644 --- a/app/src/main/java/cn/lineai/ui/component/ErrorLogsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ErrorLogsScreenView.java @@ -68,7 +68,6 @@ private void openLog(ErrorLogEntry entry) { private static IconButtonView clearButton(Context context) { IconButtonView button = new IconButtonView(context, IconButtonView.TRASH_2); button.setIconColor(LineTheme.DANGER); - button.setIconSizeDp(36, 20); return button; } } diff --git a/app/src/main/java/cn/lineai/ui/component/ExtensionDetailScreenView.java b/app/src/main/java/cn/lineai/ui/component/ExtensionDetailScreenView.java index 317b6bd3..80a3c7e4 100644 --- a/app/src/main/java/cn/lineai/ui/component/ExtensionDetailScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ExtensionDetailScreenView.java @@ -44,6 +44,8 @@ public interface Listener { void onInstallSkillFromGitHub(String location, String githubUrl); + void onOpenSkillStore(); + void onEnabledChanged(String kind, String id, boolean enabled); void onDelete(String kind, String id); @@ -82,6 +84,18 @@ public ExtensionDetailScreenView(Context context, ExtensionKindUiModel uiModel, content.addView(add, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); if ("skills".equals(kind)) { + SettingsSectionView store = new SettingsSectionView(context, getString(R.string.extension_online_store)); + store.addRow(new ActionRowView( + context, + IconButtonView.ARCHIVE, + getString(R.string.extension_skillhub_store), + getString(R.string.extension_skillhub_store_desc), + false, + true, + listener::onOpenSkillStore + ), false); + content.addView(store, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + SettingsSectionView workspace = new SettingsSectionView(context, context.getString(R.string.screen_extension_detail_workspace_share)); workspace.addRow(new ActionRowView( context, diff --git a/app/src/main/java/cn/lineai/ui/component/ExtensionsScreenView.java b/app/src/main/java/cn/lineai/ui/component/ExtensionsScreenView.java index aedb8f61..3e2da4c0 100644 --- a/app/src/main/java/cn/lineai/ui/component/ExtensionsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ExtensionsScreenView.java @@ -12,7 +12,7 @@ import android.widget.TextView; import cn.lineai.R; -public final class ExtensionsScreenView extends LinearLayout { +public final class ExtensionsScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); @@ -32,7 +32,7 @@ public ExtensionsScreenView(Context context, Listener listener) { ScrollView scrollView = new ScrollView(context); LinearLayout content = new LinearLayout(context); content.setOrientation(VERTICAL); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 12, 8, 12, 48); scrollView.addView(content, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); addView(scrollView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); @@ -44,58 +44,6 @@ public ExtensionsScreenView(Context context, Listener listener) { } private void addCard(LinearLayout content, String id, String title, String desc, String badge, int iconType) { - Context context = content.getContext(); - LinearLayout card = new LinearLayout(context); - card.setOrientation(HORIZONTAL); - card.setGravity(Gravity.CENTER_VERTICAL); - card.setClickable(true); - card.setOnClickListener(v -> listener.onOpen(id)); - card.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); - LineTheme.padding(card, LineTheme.LG, LineTheme.MD, LineTheme.MD, LineTheme.MD); - LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - cardParams.bottomMargin = LineTheme.dp(context, LineTheme.SM); - content.addView(card, cardParams); - - FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 12)); - IconButtonView icon = new IconButtonView(context, iconType); - icon.setIconColor(LineTheme.ACCENT); - icon.setIconSizeDp(44, 22); - icon.setClickable(false); - iconWrap.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44), Gravity.CENTER)); - card.addView(iconWrap, new LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44))); - - LinearLayout text = new LinearLayout(context); - text.setOrientation(VERTICAL); - LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - textParams.leftMargin = LineTheme.dp(context, LineTheme.MD); - textParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - card.addView(text, textParams); - - LinearLayout titleRow = new LinearLayout(context); - titleRow.setOrientation(HORIZONTAL); - titleRow.setGravity(Gravity.CENTER_VERTICAL); - text.addView(titleRow, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - TextView titleView = LineTheme.text(context, title, LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); - titleRow.addView(titleView, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - TextView badgeView = LineTheme.text(context, badge, LineTheme.FONT_XS, LineTheme.ACCENT, Typeface.BOLD); - badgeView.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 999)); - LineTheme.padding(badgeView, LineTheme.SM, 3, LineTheme.SM, 3); - LinearLayout.LayoutParams badgeParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - badgeParams.leftMargin = LineTheme.dp(context, LineTheme.SM); - titleRow.addView(badgeView, badgeParams); - - TextView descView = LineTheme.text(context, desc, LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - descView.setLineSpacing(LineTheme.dp(context, 3), 1f); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, LineTheme.XS); - text.addView(descView, descParams); - - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - chevron.setIconColor(LineTheme.TEXT_TERTIARY); - chevron.setIconSizeDp(20, 17); - chevron.setClickable(false); - card.addView(chevron, new LayoutParams(LineTheme.dp(context, 20), LineTheme.dp(context, 20))); + CardViewHelper.addCard(content, id, title, desc, badge, iconType, listener::onOpen); } } diff --git a/app/src/main/java/cn/lineai/ui/component/FileActionRow.java b/app/src/main/java/cn/lineai/ui/component/FileActionRow.java index 0e20b0cd..12b4d1b8 100644 --- a/app/src/main/java/cn/lineai/ui/component/FileActionRow.java +++ b/app/src/main/java/cn/lineai/ui/component/FileActionRow.java @@ -55,7 +55,9 @@ public static View create(Context context, Dialog dialog, SheetOption option, Op listener.onOptionSelected(id); } }); - LineTheme.padding(row, 0, 14, 0, 14); + LineTheme.padding(row, 0, 16, 0, 16); + row.setMinimumHeight(LineTheme.dp(context, 52)); + row.setBackground(LineTheme.pressable(context)); LinearLayout labels = new LinearLayout(context); labels.setOrientation(LinearLayout.VERTICAL); @@ -73,7 +75,7 @@ public static View create(Context context, Dialog dialog, SheetOption option, Op TextView desc = LineTheme.text(context, description, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); desc.setSingleLine(false); LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, 2); + descParams.topMargin = LineTheme.dp(context, 6); labels.addView(desc, descParams); } return row; diff --git a/app/src/main/java/cn/lineai/ui/component/FormTextFieldView.java b/app/src/main/java/cn/lineai/ui/component/FormTextFieldView.java index 063a8df7..cf22d072 100644 --- a/app/src/main/java/cn/lineai/ui/component/FormTextFieldView.java +++ b/app/src/main/java/cn/lineai/ui/component/FormTextFieldView.java @@ -20,16 +20,18 @@ public FormTextFieldView(Context context, String label, String value, String pla addView(labelView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); input = new EditText(context); + input.setId(android.view.View.generateViewId()); + labelView.setLabelFor(input.getId()); input.setText(value == null ? "" : value); input.setHint(placeholder); input.setHintTextColor(LineTheme.TEXT_TERTIARY); input.setTextColor(LineTheme.TEXT); input.setTextSize(LineTheme.FONT_MD); input.setSingleLine(!multiline); - input.setMinHeight(LineTheme.dp(context, multiline ? 120 : 44)); + input.setMinHeight(LineTheme.dp(context, multiline ? 112 : 52)); input.setGravity((multiline ? Gravity.TOP : Gravity.CENTER_VERTICAL) | Gravity.START); input.setIncludeFontPadding(false); - input.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_LIGHT, 8, LineTheme.BORDER_LIGHT)); + input.setBackground(LineTheme.fieldBackground(context)); input.setPadding(LineTheme.dp(context, LineTheme.MD), LineTheme.dp(context, LineTheme.SM), LineTheme.dp(context, LineTheme.MD), LineTheme.dp(context, LineTheme.SM)); if (secure) { input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); @@ -40,14 +42,14 @@ public FormTextFieldView(Context context, String label, String value, String pla input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); } LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - inputParams.topMargin = LineTheme.dp(context, LineTheme.XS); + inputParams.topMargin = LineTheme.dp(context, 10); addView(input, inputParams); if (hint != null && hint.length() > 0) { - TextView hintView = LineTheme.text(context, hint, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + TextView hintView = LineTheme.text(context, hint, 13, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); hintView.setLineSpacing(LineTheme.dp(context, 3), 1f); LinearLayout.LayoutParams hintParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - hintParams.topMargin = LineTheme.dp(context, LineTheme.XS); + hintParams.topMargin = LineTheme.dp(context, 10); addView(hintView, hintParams); } } diff --git a/app/src/main/java/cn/lineai/ui/component/HeaderView.java b/app/src/main/java/cn/lineai/ui/component/HeaderView.java index f2a50840..0fd71576 100644 --- a/app/src/main/java/cn/lineai/ui/component/HeaderView.java +++ b/app/src/main/java/cn/lineai/ui/component/HeaderView.java @@ -1,133 +1,86 @@ package cn.lineai.ui.component; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Typeface; +import android.text.TextUtils; import android.view.Gravity; -import android.view.View; import android.widget.LinearLayout; import android.widget.TextView; import cn.lineai.R; import cn.lineai.model.ChatUiState; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +/** Conversation navigation and the current workspace selector. */ public final class HeaderView extends LinearLayout { public interface Listener { void onMenuClick(); - void onProjectClick(); - void onPermissionClick(); - void onNewConversationClick(); - void onMoreClick(); } - - private final Paint borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final TextView projectText; private Listener listener; + private final LinearLayout brand; + private final TextView projectText; public HeaderView(Context context) { super(context); setOrientation(HORIZONTAL); setGravity(Gravity.CENTER_VERTICAL); setBackgroundColor(LineTheme.BG); - setWillNotDraw(false); - LineTheme.padding(this, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); - setMinimumHeight(LineTheme.dp(context, 58)); - - IconButtonView menu = icon(context, IconButtonView.MENU, LineTheme.TEXT, 20); + setMinimumHeight(LineTheme.dp(context, 56)); + LineTheme.padding(this, 4, 2, 8, 2); + brand = new LinearLayout(context); + brand.setGravity(Gravity.CENTER_VERTICAL); + brand.setMinimumHeight(LineTheme.dp(context, 48)); + brand.setFocusable(true); + brand.setOnClickListener(v -> { if (listener != null) listener.onProjectClick(); }); + IconButtonView menu = new IconButtonView(context, IconButtonView.MENU); + menu.setIconColor(LineTheme.TEXT_SECONDARY); + menu.setIconSizeDp(40, 19); menu.setContentDescription(context.getString(R.string.header_menu_desc)); - menu.setOnClickListener(v -> { - if (listener != null) { - listener.onMenuClick(); - } - }); - addView(menu); - - LinearLayout projectButton = new LinearLayout(context); - projectButton.setOrientation(HORIZONTAL); - projectButton.setGravity(Gravity.CENTER_VERTICAL); - projectButton.setClickable(true); - projectButton.setFocusable(true); - projectButton.setOnClickListener(v -> { - if (listener != null) { - listener.onProjectClick(); - } - }); - LinearLayout.LayoutParams projectParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - projectParams.leftMargin = LineTheme.dp(context, 4); - projectParams.rightMargin = LineTheme.dp(context, 6); - addView(projectButton, projectParams); - - View dot = new View(context); - dot.setBackground(LineTheme.rounded(context, LineTheme.ACCENT, 4)); - LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 7), LineTheme.dp(context, 7)); - projectButton.addView(dot, dotParams); - - projectText = LineTheme.textMedium(context, context.getString(R.string.header_project_default), LineTheme.FONT_MD, LineTheme.TEXT); + menu.setOnClickListener(v -> { if (listener != null) listener.onMenuClick(); }); + addView(menu, new LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 48))); + projectText = LineTheme.textMedium(context, context.getString(R.string.header_project_default), 16, LineTheme.TEXT); projectText.setSingleLine(true); - LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - labelParams.leftMargin = LineTheme.dp(context, 6); - projectButton.addView(projectText, labelParams); - - IconButtonView chevron = icon(context, IconButtonView.CHEVRON_DOWN, LineTheme.TEXT_SECONDARY, 14); - chevron.setIconSizeDp(20, 14); - projectButton.addView(chevron, new LinearLayout.LayoutParams(LineTheme.dp(context, 20), LineTheme.dp(context, 20))); - - IconButtonView shield = icon(context, IconButtonView.SHIELD, LineTheme.TEXT_SECONDARY, 18); - shield.setContentDescription(context.getString(R.string.header_permission_desc)); - shield.setOnClickListener(v -> { - if (listener != null) { - listener.onPermissionClick(); - } - }); - addView(shield); - - IconButtonView plus = icon(context, IconButtonView.PLUS, LineTheme.TEXT_SECONDARY, 20); - plus.setContentDescription(context.getString(R.string.header_new_conversation_desc)); - plus.setOnClickListener(v -> { - if (listener != null) { - listener.onNewConversationClick(); - } - }); - addView(plus); - - IconButtonView more = icon(context, IconButtonView.MORE, LineTheme.TEXT_SECONDARY, 18); - more.setContentDescription(context.getString(R.string.header_more_desc)); - more.setOnClickListener(v -> { - if (listener != null) { - listener.onMoreClick(); - } - }); - addView(more); + projectText.setEllipsize(TextUtils.TruncateAt.END); + LayoutParams titleParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + brand.addView(projectText, titleParams); + IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_DOWN); + chevron.setIconSizeDp(24, 14); + chevron.setIconColor(LineTheme.TEXT_SECONDARY); + chevron.setClickable(false); + chevron.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + brand.addView(chevron, new LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 32))); + addView(brand, new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1)); + IconButtonView permissions = new IconButtonView(context, IconButtonView.SHIELD); + permissions.setIconSizeDp(40, 19); + permissions.setIconColor(LineTheme.TEXT_SECONDARY); + permissions.setContentDescription(context.getString(R.string.header_permission_desc)); + permissions.setOnClickListener(v -> { if (listener != null) listener.onPermissionClick(); }); + addView(permissions, new LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 48))); + IconButtonView create = new IconButtonView(context, IconButtonView.PLUS); + create.setIconSizeDp(40, 19); + create.setIconColor(LineTheme.TEXT_SECONDARY); + create.setContentDescription(context.getString(R.string.header_new_conversation_desc)); + create.setOnClickListener(v -> { if (listener != null) listener.onNewConversationClick(); }); + addView(create, new LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 48))); + IconButtonView more = new IconButtonView(context, IconButtonView.MORE); + more.setIconSizeDp(40, 19); + more.setIconColor(LineTheme.TEXT_SECONDARY); + more.setContentDescription(context.getString(R.string.chat_context_more)); + more.setOnClickListener(v -> { if (listener != null) listener.onMoreClick(); }); + addView(more, new LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 48))); } - - public void setListener(Listener listener) { - this.listener = listener; + public void setListener(Listener listener) { this.listener = listener; } + @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + projectText.setMaxWidth(Math.max(0, width - getPaddingLeft() - getPaddingRight() - LineTheme.dp(getContext(), 184))); } - public void render(ChatUiState state) { - projectText.setText(state.getProjectLabel()); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - borderPaint.setColor(LineTheme.BORDER); - borderPaint.setStrokeWidth(1f); - canvas.drawLine(0, getHeight() - 1, getWidth(), getHeight() - 1, borderPaint); - } - - private IconButtonView icon(Context context, int type, int color, int iconDp) { - IconButtonView view = new IconButtonView(context, type); - view.setIconColor(color); - view.setIconSizeDp(34, iconDp); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LineTheme.dp(context, 34), LineTheme.dp(context, 34)); - view.setLayoutParams(params); - return view; + String label = state.getProjectLabel(); + if (label == null || label.isEmpty()) label = getContext().getString(R.string.header_project_default); + projectText.setText(label); + brand.setContentDescription(label); } } diff --git a/app/src/main/java/cn/lineai/ui/component/InAppBrowserScreenView.java b/app/src/main/java/cn/lineai/ui/component/InAppBrowserScreenView.java index 0a3adfea..e06b58ea 100644 --- a/app/src/main/java/cn/lineai/ui/component/InAppBrowserScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/InAppBrowserScreenView.java @@ -18,7 +18,7 @@ import cn.lineai.R; import cn.lineai.security.UrlPolicy; -public final class InAppBrowserScreenView extends LinearLayout { +public final class InAppBrowserScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); } @@ -30,40 +30,11 @@ public InAppBrowserScreenView(Context context, String url, boolean javaScriptEna setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); - LinearLayout header = new LinearLayout(context) { - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - borderPaint.setColor(LineTheme.BORDER); - borderPaint.setStrokeWidth(1f); - canvas.drawLine(0, getHeight() - 1, getWidth(), getHeight() - 1, borderPaint); - } - }; - header.setWillNotDraw(false); - header.setOrientation(HORIZONTAL); - header.setGravity(Gravity.CENTER_VERTICAL); - header.setBackgroundColor(LineTheme.SURFACE_ELEVATED); - LineTheme.padding(header, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); - - LinearLayout back = new LinearLayout(context); - back.setOrientation(HORIZONTAL); - back.setGravity(Gravity.CENTER_VERTICAL); - back.setOnClickListener(v -> listener.onBack()); - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_LEFT); - chevron.setIconColor(LineTheme.TEXT); - chevron.setIconSizeDp(22, 22); - chevron.setClickable(false); - back.addView(chevron, new LinearLayout.LayoutParams(LineTheme.dp(context, 22), LineTheme.dp(context, 22))); - back.addView(LineTheme.text(context, getContext().getString(R.string.in_app_browser_exit), LineTheme.FONT_MD, LineTheme.TEXT, Typeface.NORMAL)); - header.addView(back, new LinearLayout.LayoutParams(LineTheme.dp(context, 56), LayoutParams.WRAP_CONTENT)); - - TextView title = LineTheme.textMedium(context, url == null ? context.getString(R.string.in_app_browser_default_title) : url, LineTheme.FONT_MD, LineTheme.TEXT); - title.setGravity(Gravity.CENTER); - title.setSingleLine(true); - header.addView(title, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - header.addView(new LinearLayout(context), new LinearLayout.LayoutParams(LineTheme.dp(context, 56), 1)); - addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + addView(new ScreenHeaderView(context, context.getString(R.string.in_app_browser_default_title), listener::onBack, null), new LayoutParams(-1,-2)); + TextView address = LineTheme.text(context, url == null ? "" : url, 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + address.setSingleLine(true); address.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE); + LineTheme.padding(address,28,0,28,16); addView(address,new LayoutParams(-1,-2)); WebView webView = new WebView(context); webView.setBackgroundColor(LineTheme.BG); webView.setContentDescription(getContext().getString(R.string.in_app_browser_content_desc)); diff --git a/app/src/main/java/cn/lineai/ui/component/InsetSheetLayout.java b/app/src/main/java/cn/lineai/ui/component/InsetSheetLayout.java new file mode 100644 index 00000000..4d9159df --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/InsetSheetLayout.java @@ -0,0 +1,15 @@ +package cn.lineai.ui.component; +import android.content.Context; +import android.widget.LinearLayout; +import cn.lineai.ui.theme.LineTheme; +/** Bounded by the current parent window, including when the keyboard is visible. */ +public final class InsetSheetLayout extends LinearLayout { + public InsetSheetLayout(Context context) { super(context); setOrientation(VERTICAL); } + private int availableHeight; + public void setAvailableHeight(int height) { availableHeight = height; } + @Override protected void onMeasure(int w, int h) { + int width = Math.min(MeasureSpec.getSize(w), LineTheme.dp(getContext(),560)); + int height = availableHeight > 0 ? Math.min(MeasureSpec.getSize(h),availableHeight) : MeasureSpec.getSize(h); + super.onMeasure(MeasureSpec.makeMeasureSpec(width,MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(height,MeasureSpec.getMode(h))); + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/LegalDialog.java b/app/src/main/java/cn/lineai/ui/component/LegalDialog.java index a5f17d9b..23319fca 100644 --- a/app/src/main/java/cn/lineai/ui/component/LegalDialog.java +++ b/app/src/main/java/cn/lineai/ui/component/LegalDialog.java @@ -34,8 +34,8 @@ public static void show(Context context, String title, String message, LinearLayout panel = new LinearLayout(context); panel.setOrientation(LinearLayout.VERTICAL); - panel.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 18)); - LineTheme.padding(panel, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + panel.setBackground(LineTheme.rounded(context, LineTheme.BG, 24)); + LineTheme.padding(panel, 24, 24, 24, 24); TextView titleView = LineTheme.text(context, title == null ? "" : title, LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); panel.addView(titleView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); @@ -57,7 +57,7 @@ public static void show(Context context, String title, String message, scrollView.addView(textView, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); panel.addView(scrollView); - LinearLayout actions = new LinearLayout(context); + LinearLayout actions = new AdaptiveActionsView(context); actions.setOrientation(LinearLayout.HORIZONTAL); actions.setGravity(Gravity.END); diff --git a/app/src/main/java/cn/lineai/ui/component/LineAlertDialog.java b/app/src/main/java/cn/lineai/ui/component/LineAlertDialog.java new file mode 100644 index 00000000..e847c5ae --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/LineAlertDialog.java @@ -0,0 +1,36 @@ +package cn.lineai.ui.component; +import android.app.AlertDialog; +import android.content.Context; +import android.view.View; +import android.widget.TextView; +import cn.lineai.ui.theme.LineTheme; +public final class LineAlertDialog { + private LineAlertDialog() { } + public static final class Builder extends AlertDialog.Builder { + public Builder(Context context) { + super(context, android.graphics.Color.red(LineTheme.BG)>128 + ? android.R.style.Theme_Material_Light_Dialog_Alert : android.R.style.Theme_Material_Dialog_Alert); + } + @Override public AlertDialog create() { + AlertDialog dialog=super.create(); + if(dialog.getWindow()!=null) { + dialog.getWindow().setBackgroundDrawable(LineTheme.rounded(getContext(),LineTheme.BG,24)); + View decor=dialog.getWindow().getDecorView(); + decor.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override public void onViewAttachedToWindow(View v) { + TextView message=dialog.findViewById(android.R.id.message); + if(message!=null){message.setTextColor(LineTheme.TEXT);message.setTextSize(16);message.setLineSpacing(LineTheme.dp(getContext(),6),1);} + for(int id:new int[]{AlertDialog.BUTTON_POSITIVE,AlertDialog.BUTTON_NEGATIVE,AlertDialog.BUTTON_NEUTRAL}) { + android.widget.Button button=dialog.getButton(id); + if(button!=null){button.setTextColor(LineTheme.TEXT);button.setTextSize(14);button.setAllCaps(false);button.setMinHeight(LineTheme.dp(getContext(),48));} + } + dialog.getWindow().setLayout(DialogDimensions.insetDialogWidth(getContext()),-2); + v.removeOnAttachStateChangeListener(this); + } + @Override public void onViewDetachedFromWindow(View v) { } + }); + } + return dialog; + } + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/MCPSettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/MCPSettingsScreenView.java index da7f122d..eca65ee9 100644 --- a/app/src/main/java/cn/lineai/ui/component/MCPSettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/MCPSettingsScreenView.java @@ -41,7 +41,7 @@ public MCPSettingsScreenView(Context context, McpSettingsState state, Listener l this.listener = listener; this.state = state == null ? new McpSettingsState(EXECUTION_LOCAL, null) : state; LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 16, 8, 16, 48); addExecutionTarget(content); if (EXECUTION_SSH.equals(this.state.getExecutionMode())) { @@ -54,16 +54,13 @@ private void addExecutionTarget(LinearLayout content) { Context context = content.getContext(); LinearLayout card = card(context); card.addView(title(context, context.getString(R.string.screen_mcp_section_execution))); - LinearLayout segment = new LinearLayout(context); - segment.setOrientation(HORIZONTAL); - segment.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 8)); - LineTheme.padding(segment, 3, 3, 3, 3); - addModeButton(segment, context.getString(R.string.screen_mcp_execution_local), EXECUTION_LOCAL); - addModeButton(segment, context.getString(R.string.screen_mcp_execution_ssh), EXECUTION_SSH); - addModeButton(segment, context.getString(R.string.screen_mcp_execution_terminal_provider), EXECUTION_TERMINAL_PROVIDER); - LinearLayout.LayoutParams segmentParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 42)); - segmentParams.topMargin = LineTheme.dp(context, LineTheme.SM); - card.addView(segment, segmentParams); + for (String mode : new String[] {EXECUTION_LOCAL, EXECUTION_SSH, EXECUTION_TERMINAL_PROVIDER}) { + int label = EXECUTION_LOCAL.equals(mode) ? R.string.screen_mcp_execution_local : EXECUTION_SSH.equals(mode) ? R.string.screen_mcp_execution_ssh : R.string.screen_mcp_execution_terminal_provider; + LayoutParams modeParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + modeParams.topMargin = LineTheme.dp(context, 8); + card.addView(new OptionRowView(context, IconButtonView.TERMINAL, context.getString(label), null, + mode.equals(state.getExecutionMode()), () -> listener.onExecutionModeChanged(mode)), modeParams); + } String executionDesc; if (EXECUTION_LOCAL.equals(state.getExecutionMode())) { executionDesc = context.getString(R.string.screen_mcp_execution_local_desc); @@ -88,7 +85,7 @@ private void addSshConnection(LinearLayout content) { descParams.topMargin = LineTheme.dp(context, 2); card.addView(desc, descParams); - LinearLayout actions = new LinearLayout(context); + LinearLayout actions = new AdaptiveActionsView(context); actions.setOrientation(HORIZONTAL); LinearLayout ssh = actionButton(context, context.getString(R.string.screen_mcp_ssh_settings), IconButtonView.SERVER, true, v -> listener.onOpenSshSettings()); LinearLayout termux = actionButton(context, context.getString(R.string.screen_mcp_termux_integration), IconButtonView.SMARTPHONE, false, v -> listener.onOpenTermuxIntegration()); @@ -132,7 +129,7 @@ private void addToolCard(LinearLayout content, int iconType, McpToolConfig confi icon.setIconColor(config.isEnabled() ? LineTheme.ACCENT : LineTheme.TEXT_TERTIARY); icon.setIconSizeDp(36, 18); icon.setClickable(false); - icon.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 18)); + header.addView(icon, new LinearLayout.LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36))); LinearLayout labels = new LinearLayout(context); @@ -153,24 +150,6 @@ private void addToolCard(LinearLayout content, int iconType, McpToolConfig confi header.addView(toggle, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); card.addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - View divider = new View(context); - divider.setBackgroundColor(LineTheme.BORDER_LIGHT); - LinearLayout.LayoutParams dividerParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 1); - dividerParams.topMargin = LineTheme.dp(context, LineTheme.MD); - card.addView(divider, dividerParams); - - FlowLayoutView toolWrap = new FlowLayoutView(context); - toolWrap.setSpacingDp(LineTheme.SM, LineTheme.SM); - for (String tool : config.getTools()) { - TextView badge = LineTheme.text(context, tool, LineTheme.FONT_XS, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); - badge.setTypeface(Typeface.MONOSPACE); - badge.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_LIGHT, 4)); - LineTheme.padding(badge, LineTheme.SM, 2, LineTheme.SM, 2); - toolWrap.addView(badge); - } - LinearLayout.LayoutParams toolWrapParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - toolWrapParams.topMargin = LineTheme.dp(context, LineTheme.SM); - card.addView(toolWrap, toolWrapParams); addCard(content, card); } @@ -198,8 +177,11 @@ private void tintSwitch(Switch toggle, CompoundButton.OnCheckedChangeListener li private LinearLayout card(Context context) { LinearLayout card = new LinearLayout(context); card.setOrientation(LinearLayout.VERTICAL); - card.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LineTheme.padding(card, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + card.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 14, LineTheme.BORDER_LIGHT)); + LineTheme.padding(card, 16, 14, 16, 16); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.bottomMargin = LineTheme.dp(context, 14); + card.setLayoutParams(params); return card; } diff --git a/app/src/main/java/cn/lineai/ui/component/MainChatViewLayoutBuilder.java b/app/src/main/java/cn/lineai/ui/component/MainChatViewLayoutBuilder.java index b125522b..77c443d1 100644 --- a/app/src/main/java/cn/lineai/ui/component/MainChatViewLayoutBuilder.java +++ b/app/src/main/java/cn/lineai/ui/component/MainChatViewLayoutBuilder.java @@ -63,7 +63,12 @@ private MainChatViewLayoutBuilder() { * @return a {@link Result} carrying {@code contentView} and {@code screenHost}. */ public static Result build(Context context) { - LinearLayout contentView = new LinearLayout(context); + LinearLayout contentView = new LinearLayout(context) { + @Override protected void onMeasure(int widthSpec, int heightSpec) { + int width = Math.min(View.MeasureSpec.getSize(widthSpec), LineTheme.dp(getContext(), 792)); + super.onMeasure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), heightSpec); + } + }; contentView.setOrientation(LinearLayout.VERTICAL); contentView.setLayoutParams(new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); @@ -100,11 +105,11 @@ public static void installSystemBarInsetsHandling(View host, View contentView, V return; } host.setOnApplyWindowInsetsListener((view, insets) -> { - Insets systemBars = insets.getInsets(WindowInsets.Type.systemBars()); + Insets systemBars = insets.getInsets(WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); Insets ime = insets.getInsets(WindowInsets.Type.ime()); int bottomInset = Math.max(systemBars.bottom, ime.bottom); - contentView.setPadding(0, systemBars.top, 0, bottomInset); - screenHost.setPadding(0, systemBars.top, 0, bottomInset); + contentView.setPadding(systemBars.left, systemBars.top, systemBars.right, bottomInset); + screenHost.setPadding(systemBars.left, systemBars.top, systemBars.right, bottomInset); return insets; }); host.post(host::requestApplyInsets); diff --git a/app/src/main/java/cn/lineai/ui/component/McpExtensionEditScreenView.java b/app/src/main/java/cn/lineai/ui/component/McpExtensionEditScreenView.java index c0f48ca3..10043c03 100644 --- a/app/src/main/java/cn/lineai/ui/component/McpExtensionEditScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/McpExtensionEditScreenView.java @@ -271,7 +271,7 @@ private void addForm(LinearLayout content, String title, android.view.View first Context context = content.getContext(); LinearLayout group = new LinearLayout(context); group.setOrientation(LinearLayout.VERTICAL); - group.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); + group.setBackground(null); LineTheme.padding(group, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); group.addView(first, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout.LayoutParams secondParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); @@ -310,7 +310,7 @@ private String trimTrailingSlash(String url) { return value; } - private static final class HeaderRow extends LinearLayout { + private static final class HeaderRow extends ScreenSurfaceView { private final EditText nameInput; private final EditText valueInput; diff --git a/app/src/main/java/cn/lineai/ui/component/MemorySettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/MemorySettingsScreenView.java index 79556615..6407ddda 100644 --- a/app/src/main/java/cn/lineai/ui/component/MemorySettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/MemorySettingsScreenView.java @@ -30,7 +30,7 @@ import java.util.Locale; import java.util.Set; -public final class MemorySettingsScreenView extends LinearLayout { +public final class MemorySettingsScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); @@ -385,26 +385,13 @@ private Dialog createDialog() { private LinearLayout dialogPanel(Context context) { LinearLayout panel = new LinearLayout(context); panel.setOrientation(LinearLayout.VERTICAL); - panel.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); + panel.setBackground(LineTheme.rounded(context, LineTheme.BG, 24)); LineTheme.padding(panel, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); return panel; } private void showPanel(Dialog dialog, LinearLayout panel) { - ScrollView scrollView = new ScrollView(getContext()); - scrollView.setFillViewport(false); - scrollView.addView(panel, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - dialog.setContentView(scrollView); - dialog.show(); - Window window = dialog.getWindow(); - if (window != null) { - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - WindowManager.LayoutParams params = new WindowManager.LayoutParams(); - params.copyFrom(window.getAttributes()); - params.width = Math.min(getResources().getDisplayMetrics().widthPixels - LineTheme.dp(getContext(), 32), LineTheme.dp(getContext(), 560)); - params.height = WindowManager.LayoutParams.WRAP_CONTENT; - window.setAttributes(params); - } + DialogBuilder.showInset(dialog, panel); } private TextView titleView(String text) { diff --git a/app/src/main/java/cn/lineai/ui/component/MessageActionBarView.java b/app/src/main/java/cn/lineai/ui/component/MessageActionBarView.java index da94c73d..730fdfb4 100644 --- a/app/src/main/java/cn/lineai/ui/component/MessageActionBarView.java +++ b/app/src/main/java/cn/lineai/ui/component/MessageActionBarView.java @@ -25,7 +25,7 @@ public MessageActionBarView(Context context, int align, boolean recallEnabled, b super(context); setOrientation(HORIZONTAL); setGravity(align == ALIGN_RIGHT ? Gravity.END : Gravity.START); - setMinimumHeight(LineTheme.dp(context, 22)); + setMinimumHeight(LineTheme.dp(context, 44)); copyButton = icon(context, IconButtonView.COPY); copyButton.setContentDescription(context.getString(R.string.message_action_copy_desc)); @@ -130,13 +130,13 @@ public interface RecallListener { private IconButtonView icon(Context context, int type) { IconButtonView icon = new IconButtonView(context, type); icon.setIconColor(LineTheme.TEXT_TERTIARY); - icon.setIconPaddingDp(4, 3, 5, 4); + icon.setIconSizeDp(44, 16); icon.setClickable(true); return icon; } private LinearLayout.LayoutParams iconParams(Context context) { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 22)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LineTheme.dp(context, 40), LineTheme.dp(context, 44)); params.rightMargin = LineTheme.dp(context, LineTheme.XS); return params; } diff --git a/app/src/main/java/cn/lineai/ui/component/ModelAddOptionsScreenView.java b/app/src/main/java/cn/lineai/ui/component/ModelAddOptionsScreenView.java index 71c417c7..e4ea2554 100644 --- a/app/src/main/java/cn/lineai/ui/component/ModelAddOptionsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ModelAddOptionsScreenView.java @@ -27,7 +27,7 @@ public interface Listener { public ModelAddOptionsScreenView(Context context, Listener listener) { super(context, context.getString(R.string.screen_model_add_options_title), listener::onBack, null); LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + LineTheme.padding(content, 16, 8, 16, 48); addLargeCard(content, IconButtonView.SLIDERS_HORIZONTAL, context.getString(R.string.screen_model_add_options_custom), context.getString(R.string.screen_model_add_options_custom_desc), listener::onCustom); @@ -57,89 +57,19 @@ public ModelAddOptionsScreenView(Context context, Listener listener) { } private void addLargeCard(LinearLayout content, int iconType, String title, String desc, Runnable onClick) { - Context context = content.getContext(); - LinearLayout card = new LinearLayout(context); - card.setOrientation(HORIZONTAL); - card.setGravity(Gravity.CENTER_VERTICAL); - card.setMinimumHeight(LineTheme.dp(context, 92)); - card.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER_LIGHT)); - card.setClickable(true); - card.setOnClickListener(v -> onClick.run()); - LineTheme.padding(card, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); - - FrameLayout largeIcon = new FrameLayout(context); - largeIcon.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 8)); - IconButtonView icon = new IconButtonView(context, iconType); - icon.setIconColor(LineTheme.ACCENT); - icon.setIconSizeDp(44, 22); - icon.setClickable(false); - largeIcon.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44), Gravity.CENTER)); - card.addView(largeIcon, new LinearLayout.LayoutParams(LineTheme.dp(context, 44), LineTheme.dp(context, 44))); - - LinearLayout text = new LinearLayout(context); - text.setOrientation(VERTICAL); - LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - textParams.leftMargin = LineTheme.dp(context, LineTheme.MD); - textParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - card.addView(text, textParams); - text.addView(LineTheme.text(context, title, LineTheme.FONT_MD, LineTheme.TEXT, Typeface.BOLD), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - TextView descView = LineTheme.text(context, desc, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - descView.setLineSpacing(LineTheme.dp(context, 3), 1f); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, LineTheme.XS); - text.addView(descView, descParams); - - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - chevron.setIconColor(LineTheme.TEXT_TERTIARY); - chevron.setIconSizeDp(19, 17); - chevron.setClickable(false); - card.addView(chevron, new LinearLayout.LayoutParams(LineTheme.dp(context, 19), LineTheme.dp(context, 19))); - - LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - cardParams.bottomMargin = LineTheme.dp(context, LineTheme.SM); - content.addView(card, cardParams); + content.addView(new ActionRowView(getContext(), iconType, title, desc, false, true, onClick), cardParams()); } - private void addProvider(LinearLayout content, ModelProviderPreset preset, Listener listener) { - Context context = content.getContext(); - String name = ModelProviderPresetStrings.getLabel(context, preset.getId()); - LinearLayout row = new LinearLayout(context); - row.setOrientation(HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER_LIGHT)); - row.setClickable(true); - row.setOnClickListener(v -> listener.onProvider(preset.getId())); - LineTheme.padding(row, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); - - TextView initial = LineTheme.text(context, name.substring(0, 1), LineTheme.FONT_MD, LineTheme.ACCENT, Typeface.BOLD); - initial.setGravity(Gravity.CENTER); - initial.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 8)); - row.addView(initial, new LinearLayout.LayoutParams(LineTheme.dp(context, 38), LineTheme.dp(context, 38))); - - LinearLayout text = new LinearLayout(context); - text.setOrientation(VERTICAL); - LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - textParams.leftMargin = LineTheme.dp(context, LineTheme.MD); - textParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - row.addView(text, textParams); - TextView title = LineTheme.text(context, name, LineTheme.FONT_MD, LineTheme.TEXT, Typeface.BOLD); - title.setSingleLine(true); - text.addView(title, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - TextView sub = LineTheme.text(context, ModelProviderPresetStrings.getDesc(context, preset.getId()) + " · " + protocolLabel(preset), LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - sub.setSingleLine(true); - LinearLayout.LayoutParams subParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - subParams.topMargin = LineTheme.dp(context, 3); - text.addView(sub, subParams); - - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - chevron.setIconColor(LineTheme.TEXT_TERTIARY); - chevron.setIconSizeDp(18, 16); - chevron.setClickable(false); - row.addView(chevron, new LinearLayout.LayoutParams(LineTheme.dp(context, 18), LineTheme.dp(context, 18))); + content.addView(new ActionRowView(getContext(), IconButtonView.BOX, + ModelProviderPresetStrings.getLabel(getContext(), preset.getId()), + ModelProviderPresetStrings.getDesc(getContext(), preset.getId()), false, true, + () -> listener.onProvider(preset.getId())), cardParams()); + } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - params.bottomMargin = LineTheme.dp(context, LineTheme.SM); - content.addView(row, params); + private LayoutParams cardParams() { + LayoutParams params = new LayoutParams(-1, -2); + params.bottomMargin = LineTheme.dp(getContext(), 8); + return params; } private String protocolLabel(ModelProviderPreset preset) { diff --git a/app/src/main/java/cn/lineai/ui/component/ModelAddScreenView.java b/app/src/main/java/cn/lineai/ui/component/ModelAddScreenView.java index b65df485..ffa32986 100644 --- a/app/src/main/java/cn/lineai/ui/component/ModelAddScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ModelAddScreenView.java @@ -32,7 +32,7 @@ import java.util.ArrayList; import java.util.List; -public final class ModelAddScreenView extends LinearLayout { +public final class ModelAddScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); void onSave(ModelConfig model); @@ -93,14 +93,16 @@ public ModelAddScreenView(Context context, ModelProviderPreset preset, boolean l setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); - saveAction = LineTheme.textMedium(context, context.getString(R.string.common_save), LineTheme.FONT_MD, LineTheme.TEXT_TERTIARY); + saveAction = LineTheme.textMedium(context, context.getString(R.string.common_save), 13, LineTheme.TEXT_TERTIARY); saveAction.setGravity(Gravity.CENTER); - LineTheme.padding(saveAction, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + LineTheme.padding(saveAction, 10, 8, 10, 8); + saveAction.setMinHeight(LineTheme.dp(context, 48)); - testAction = LineTheme.textMedium(context, context.getString(R.string.screen_model_add_test_button), LineTheme.FONT_MD, LineTheme.ACCENT); + testAction = LineTheme.textMedium(context, context.getString(R.string.screen_model_add_test_button), 13, LineTheme.ACCENT); testAction.setGravity(Gravity.CENTER); testAction.setVisibility(local ? GONE : VISIBLE); - LineTheme.padding(testAction, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + LineTheme.padding(testAction, 10, 8, 10, 8); + testAction.setMinHeight(LineTheme.dp(context, 48)); LinearLayout headerActions = new LinearLayout(context); headerActions.setOrientation(HORIZONTAL); @@ -115,47 +117,15 @@ public ModelAddScreenView(Context context, ModelProviderPreset preset, boolean l ScrollView scrollView = new ScrollView(context); LinearLayout content = new LinearLayout(context); content.setOrientation(VERTICAL); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + LineTheme.padding(content, 28, 8, 28, 48); scrollView.addView(content, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); addView(scrollView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); - providerLabelView = ModelFormHelper.label(context, providerTitle()); + providerLabelView = ModelFormHelper.label(context, context.getString(R.string.screen_model_add_provider_title)); content.addView(providerLabelView, ModelFormHelper.labelParams(context, LineTheme.LG, LineTheme.SM)); LinearLayout providerRow = new LinearLayout(context); - providerRow.setOrientation(HORIZONTAL); - for (int i = 0; i < providerLabels.length; i++) { - final int index = i; - boolean enabled = !lockedPreset || isActiveProviderIndex(index); - ModelFormHelper.addToggle(providerRow, providerLabels[i], isActiveProviderIndex(index), enabled, () -> { - if (index == 3) { - Toast.makeText(context, R.string.screen_model_add_open_local_form, Toast.LENGTH_SHORT).show(); - return; - } - if (this.local) { - Toast.makeText(context, R.string.screen_model_add_open_custom_form, Toast.LENGTH_SHORT).show(); - return; - } - if (!lockedPreset) { - protocolType[0] = protocolForIndex(index); - fetchedModelIds.clear(); - selectedModelId[0] = ""; - if (modelIdInput != null) { - modelIdInput.setText(""); - } - if (compressionSection != null) { - compressionSection.clearFetched(); - } - updateProviderToggles(providerRow); - updateBaseUrlHint(); - renderModelIdInput(customIdSwitch != null && customIdSwitch.isChecked()); - if (compressionSection != null) { - compressionSection.updateForProtocolChange(); - } - updateQueryState(); - updateSaveState(); - } - }); - } + providerRow.setOrientation(VERTICAL); + updateProviderToggles(providerRow); content.addView(providerRow, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); content.addView(ModelFormHelper.label(context, context.getString(R.string.screen_model_add_field_name)), ModelFormHelper.labelParams(context, LineTheme.LG, LineTheme.SM)); @@ -232,6 +202,7 @@ public ModelAddScreenView(Context context, ModelProviderPreset preset, boolean l fetchModelCatalog(); }); + int advancedStart = content.getChildCount(); content.addView(ModelFormHelper.label(context, context.getString(R.string.screen_model_add_field_tool_call_limit)), ModelFormHelper.labelParams(context, LineTheme.LG, LineTheme.SM)); toolCallLimitInput = ModelFormHelper.input(context, String.valueOf(editing ? editingModel.getToolCallLimit() : ModelConfig.DEFAULT_TOOL_CALL_LIMIT), context.getString(R.string.screen_model_add_hint_tool_call_limit), false, false); toolCallLimitInput.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED); @@ -270,6 +241,7 @@ public void onStateChanged() { () -> ModelFormHelper.value(apiKeyInput) ); content.addView(compressionSection, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + DisclosureSectionView.foldTail(content, advancedStart, context.getString(R.string.sheet_title_advanced), false); } TextWatcher watcher = new TextWatcher() { @@ -335,13 +307,13 @@ private void addLocalUi(Context context, LinearLayout content) { card.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_LIGHT, 12, LineTheme.BORDER_LIGHT)); LineTheme.padding(card, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 8)); + iconWrap.setBackground(null); IconButtonView fileIcon = new IconButtonView(context, IconButtonView.FILE_UP); fileIcon.setIconColor(LineTheme.ACCENT); - fileIcon.setIconSizeDp(38, 20); + fileIcon.setIconSizeDp(28, 16); fileIcon.setClickable(false); - iconWrap.addView(fileIcon, new FrameLayout.LayoutParams(LineTheme.dp(context, 38), LineTheme.dp(context, 38), Gravity.CENTER)); - card.addView(iconWrap, new LinearLayout.LayoutParams(LineTheme.dp(context, 38), LineTheme.dp(context, 38))); + iconWrap.addView(fileIcon, new FrameLayout.LayoutParams(LineTheme.dp(context, 28), LineTheme.dp(context, 28), Gravity.CENTER)); + card.addView(iconWrap, new LinearLayout.LayoutParams(LineTheme.dp(context, 28), LineTheme.dp(context, 28))); LinearLayout fileText = new LinearLayout(context); fileText.setOrientation(VERTICAL); @@ -590,14 +562,59 @@ private int parseContextSize() { return ContextSizeParser.parse(ModelFormHelper.value(contextSizeInput)); } + private String activeProviderLabel() { + for (int i = 0; i < providerLabels.length; i++) if (isActiveProviderIndex(i)) return providerLabels[i]; + return providerTitle(); + } private void updateProviderToggles(LinearLayout providerRow) { - for (int i = 0; i < providerRow.getChildCount(); i++) { - TextView button = (TextView) providerRow.getChildAt(i); - boolean active = isActiveProviderIndex(i); - button.setTextColor(active ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_SECONDARY); - button.setBackground(LineTheme.rounded(getContext(), active ? LineTheme.ACCENT : LineTheme.SURFACE_LIGHT, 12)); - button.setAlpha(lockedPreset && !active ? 0.45f : 1f); - } + providerRow.removeAllViews(); + TextView selected = LineTheme.text(getContext(), activeProviderLabel() + (lockedPreset ? "" : " ›"), 16, LineTheme.TEXT, Typeface.NORMAL); + selected.setMinimumHeight(LineTheme.dp(getContext(), 52)); + selected.setGravity(Gravity.CENTER_VERTICAL); LineTheme.padding(selected, 16, 12, 16, 12); + selected.setBackground(LineTheme.fieldBackground(getContext())); + providerRow.addView(selected, new LayoutParams(-1, -2)); + if (lockedPreset) return; + selected.setOnClickListener(v -> { + android.app.Dialog dialog = DialogBuilder.create(getContext()); + LinearLayout choices = new LinearLayout(getContext()); choices.setOrientation(VERTICAL); + LineTheme.padding(choices, 12, 16, 12, 16); + for (int i = 0; i < providerLabels.length; i++) { + final int index = i; + choices.addView(new OptionRowView(getContext(), IconButtonView.BOX, providerLabels[i], null, + isActiveProviderIndex(i), () -> { dialog.dismiss(); selectProvider(index, providerRow); })); + } + DialogBuilder.showBottomSheet(dialog, choices); + }); + } + private void selectProvider(int index, LinearLayout providerRow) { + Context context = getContext(); + if (index == 3) { + Toast.makeText(context, R.string.screen_model_add_open_local_form, Toast.LENGTH_SHORT).show(); + return; + } + if (this.local) { + Toast.makeText(context, R.string.screen_model_add_open_custom_form, Toast.LENGTH_SHORT).show(); + return; + } + if (!lockedPreset) { + protocolType[0] = protocolForIndex(index); + fetchedModelIds.clear(); + selectedModelId[0] = ""; + if (modelIdInput != null) { + modelIdInput.setText(""); + } + if (compressionSection != null) { + compressionSection.clearFetched(); + } + updateProviderToggles(providerRow); + updateBaseUrlHint(); + renderModelIdInput(customIdSwitch != null && customIdSwitch.isChecked()); + if (compressionSection != null) { + compressionSection.updateForProtocolChange(); + } + updateQueryState(); + updateSaveState(); + } } private void updateBaseUrlHint() { @@ -605,11 +622,12 @@ private void updateBaseUrlHint() { return; } baseUrlHintView.setText(hintFor(lockedPreset ? preset : null)); + baseUrlHintView.setVisibility(baseUrlHintView.getText().length() == 0 ? GONE : VISIBLE); if (!lockedPreset && baseUrlInput != null) { baseUrlInput.setHint(placeholderFor(protocolType[0])); } if (providerLabelView != null) { - providerLabelView.setText(providerTitle()); + providerLabelView.setText(getContext().getString(R.string.screen_model_add_provider_title)); } if (compressionSection != null) { compressionSection.updateForProtocolChange(); diff --git a/app/src/main/java/cn/lineai/ui/component/ModelFormHelper.java b/app/src/main/java/cn/lineai/ui/component/ModelFormHelper.java index ad5726da..d4d5bae9 100644 --- a/app/src/main/java/cn/lineai/ui/component/ModelFormHelper.java +++ b/app/src/main/java/cn/lineai/ui/component/ModelFormHelper.java @@ -24,7 +24,7 @@ public static TextView label(Context context, String text) { public static LinearLayout.LayoutParams labelParams(Context context, int top, int bottom) { LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); - params.topMargin = LineTheme.dp(context, top); + params.topMargin = LineTheme.dp(context, top == 0 ? 0 : Math.max(24, top)); params.bottomMargin = LineTheme.dp(context, bottom); return params; } @@ -37,9 +37,9 @@ public static EditText input(Context context, String value, String placeholder, input.setTextColor(LineTheme.TEXT); input.setTextSize(LineTheme.FONT_MD); input.setSingleLine(!multiline); - input.setMinHeight(LineTheme.dp(context, multiline ? 120 : 48)); + input.setMinHeight(LineTheme.dp(context, multiline ? 112 : 52)); input.setIncludeFontPadding(false); - input.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_LIGHT, 12, LineTheme.BORDER_LIGHT)); + input.setBackground(LineTheme.fieldBackground(context)); input.setPadding(LineTheme.dp(context, LineTheme.LG), LineTheme.dp(context, LineTheme.MD), LineTheme.dp(context, LineTheme.LG), LineTheme.dp(context, LineTheme.MD)); input.setInputType(secure ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD @@ -51,15 +51,17 @@ public static EditText input(Context context, String value, String placeholder, public static void addToggle(LinearLayout row, String label, boolean active, boolean enabled, Runnable onClick) { Context context = row.getContext(); TextView button = LineTheme.text(context, label, LineTheme.FONT_MD, active ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT_SECONDARY, Typeface.BOLD); - button.setGravity(Gravity.CENTER); + button.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); + button.setMinimumHeight(LineTheme.dp(context, 48)); + LineTheme.padding(button, 16, 12, 16, 12); button.setBackground(LineTheme.rounded(context, active ? LineTheme.ACCENT : LineTheme.SURFACE_LIGHT, 12)); button.setAlpha(enabled || active ? 1f : 0.45f); if (enabled && onClick != null) { button.setOnClickListener(v -> onClick.run()); } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, LineTheme.dp(context, 46), 1f); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); if (row.getChildCount() > 0) { - params.leftMargin = LineTheme.dp(context, LineTheme.SM); + params.topMargin = LineTheme.dp(context, 6); } row.addView(button, params); } diff --git a/app/src/main/java/cn/lineai/ui/component/ModelListScreenView.java b/app/src/main/java/cn/lineai/ui/component/ModelListScreenView.java index 0a4f06c4..7c9875b5 100644 --- a/app/src/main/java/cn/lineai/ui/component/ModelListScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ModelListScreenView.java @@ -21,7 +21,7 @@ import java.util.List; import java.util.Set; -public final class ModelListScreenView extends LinearLayout { +public final class ModelListScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); @@ -70,7 +70,7 @@ public ModelListScreenView( ScrollView scrollView = new ScrollView(context); list = new LinearLayout(context); list.setOrientation(VERTICAL); - LineTheme.padding(list, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(list, 16, 8, 16, 48); scrollView.addView(list, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); addView(scrollView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); @@ -106,7 +106,7 @@ private void renderHeader() { if (allowManagement) { add = new IconButtonView(context, IconButtonView.PLUS); add.setIconColor(LineTheme.TEXT); - add.setIconSizeDp(36, 20); + add.setContentDescription(context.getString(R.string.screen_model_add_options_title)); add.setOnClickListener(v -> listener.onAddModel()); } headerHost.addView( @@ -158,34 +158,28 @@ private void addModel(LinearLayout list, ModelConfig model, boolean selected, bo } return true; }); - int background = checked ? LineTheme.ACCENT_MUTED : LineTheme.BG; - int border = selected || checked ? LineTheme.ACCENT : Color.TRANSPARENT; + int background = selected || checked ? LineTheme.INPUT_BG : LineTheme.SURFACE_ELEVATED; + int border = LineTheme.BORDER_LIGHT; card.setBackground(LineTheme.roundedStroke(context, background, 12, border)); - LineTheme.padding(card, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + LineTheme.padding(card, 12, 20, 12, 20); LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); cardParams.bottomMargin = LineTheme.dp(context, LineTheme.SM); list.addView(card, cardParams); String provider = displayProvider(model); - TextView badge = LineTheme.text(context, provider, LineTheme.FONT_XS, LineTheme.TEXT_ON_COLOR, Typeface.BOLD); - badge.setGravity(Gravity.CENTER); - badge.setBackground(LineTheme.rounded(context, badgeColor(model), 8)); - LinearLayout.LayoutParams badgeParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - badgeParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - LineTheme.padding(badge, LineTheme.SM, 4, LineTheme.SM, 4); - card.addView(badge, badgeParams); - LinearLayout info = new LinearLayout(context); info.setOrientation(VERTICAL); card.addView(info, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); TextView title = LineTheme.textMedium(context, model.getName(), LineTheme.FONT_MD, LineTheme.TEXT); - title.setSingleLine(true); + title.setMaxLines(2); + title.setEllipsize(android.text.TextUtils.TruncateAt.END); info.addView(title, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - TextView sub = LineTheme.text(context, model.getModelId(), LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - sub.setSingleLine(true); + TextView sub = LineTheme.text(context, provider + " / " + model.getModelId(), LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + sub.setMaxLines(2); + sub.setEllipsize(android.text.TextUtils.TruncateAt.END); LinearLayout.LayoutParams subParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - subParams.topMargin = LineTheme.dp(context, 2); + subParams.topMargin = LineTheme.dp(context, 6); info.addView(sub, subParams); if (!multiSelectedIds.isEmpty()) { @@ -287,20 +281,12 @@ private Dialog createBottomDialog(Context context) { private LinearLayout createBottomPanel(Context context) { LinearLayout panel = new LinearLayout(context); panel.setOrientation(VERTICAL); - panel.setBackground(LineTheme.roundedTop(context, LineTheme.SURFACE_ELEVATED, 16)); + panel.setBackground(LineTheme.rounded(context, LineTheme.BG, 24)); return panel; } private void showBottomDialog(Dialog dialog, LinearLayout panel) { - dialog.setContentView(panel); - dialog.show(); - Window window = dialog.getWindow(); - if (window == null) { - return; - } - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - window.setGravity(Gravity.BOTTOM); + DialogBuilder.showBottomSheet(dialog, panel); } private void addHandle(LinearLayout panel) { @@ -347,19 +333,20 @@ private void addActionRow(LinearLayout panel, String label, String desc, Runnabl if (desc != null && desc.length() > 0) { TextView descView = LineTheme.text(context, desc, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, 2); + descParams.topMargin = LineTheme.dp(context, 6); labels.addView(descView, descParams); } panel.addView(row, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } private void addBottomInset(LinearLayout panel) { - panel.addView(new View(panel.getContext()), new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(panel.getContext(), 34))); + panel.setPadding(0, 0, 0, LineTheme.dp(panel.getContext(), 12)); } private String displayProvider(ModelConfig model) { String provider = model.getProviderLabel(); - if (provider == null || provider.length() == 0 || "自定义".equals(provider)) { + String customLabel = getContext().getString(R.string.model_provider_custom); + if (provider == null || provider.length() == 0 || customLabel.equals(provider)) { return model.getProtocolType().getLabel(); } return provider; diff --git a/app/src/main/java/cn/lineai/ui/component/ModelPickerDialog.java b/app/src/main/java/cn/lineai/ui/component/ModelPickerDialog.java index 499222a8..0329a0f5 100644 --- a/app/src/main/java/cn/lineai/ui/component/ModelPickerDialog.java +++ b/app/src/main/java/cn/lineai/ui/component/ModelPickerDialog.java @@ -35,7 +35,7 @@ public static void show(Context context, List modelIds, String selectedI LinearLayout panel = new LinearLayout(context); panel.setOrientation(LinearLayout.VERTICAL); - panel.setBackground(LineTheme.roundedTop(context, LineTheme.SURFACE_ELEVATED, 16)); + panel.setBackground(LineTheme.rounded(context, LineTheme.BG, 24)); View handle = new View(context); handle.setBackground(LineTheme.rounded(context, LineTheme.TEXT_TERTIARY, 2)); @@ -53,25 +53,24 @@ public static void show(Context context, List modelIds, String selectedI divider.setBackgroundColor(LineTheme.BORDER_LIGHT); panel.addView(divider, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)); - ScrollView scroll = new ScrollView(context); + ScrollView scroll = new cn.lineai.ui.theme.BoundedScrollView(context, 420); LinearLayout list = new LinearLayout(context); list.setOrientation(LinearLayout.VERTICAL); scroll.addView(list, new ScrollView.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - int maxHeight = LineTheme.dp(context, 420); - panel.addView(scroll, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, maxHeight)); + panel.addView(scroll, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); for (String id : modelIds) { addRow(list, dialog, id, id.equals(selectedId), false, listener); } addRow(list, dialog, context.getString(R.string.screen_model_add_custom_id_picker), false, true, listener); - panel.addView(new View(context), new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LineTheme.dp(context, 34))); + panel.setPadding(0, 0, 0, LineTheme.dp(context, 12)); dialog.setContentView(panel); dialog.show(); Window window = dialog.getWindow(); if (window != null) { window.setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(Color.TRANSPARENT)); - window.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + window.setLayout(DialogDimensions.insetDialogWidth(context), LinearLayout.LayoutParams.WRAP_CONTENT); window.setGravity(Gravity.BOTTOM); } } diff --git a/app/src/main/java/cn/lineai/ui/component/OptionRowView.java b/app/src/main/java/cn/lineai/ui/component/OptionRowView.java index f7e90737..6d0704bb 100644 --- a/app/src/main/java/cn/lineai/ui/component/OptionRowView.java +++ b/app/src/main/java/cn/lineai/ui/component/OptionRowView.java @@ -36,9 +36,9 @@ public OptionRowView(Context context, int iconType, String label, String desc, b content.addView(labelView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); if (desc != null && desc.length() > 0) { - TextView descView = LineTheme.text(context, desc, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + TextView descView = LineTheme.text(context, desc, 14, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, 2); + descParams.topMargin = LineTheme.dp(context, 6); content.addView(descView, descParams); } @@ -51,9 +51,10 @@ public OptionRowView(Context context, int iconType, String label, String desc, b public void setActive(boolean active) { this.active = active; - setBackgroundColor(active ? LineTheme.ACCENT_MUTED : android.graphics.Color.TRANSPARENT); - icon.setIconColor(active ? LineTheme.ACCENT : LineTheme.TEXT_SECONDARY); - labelView.setTextColor(active ? LineTheme.ACCENT : LineTheme.TEXT); + setBackground(active ? LineTheme.roundedStroke(getContext(), LineTheme.INPUT_BG, 12, LineTheme.ACCENT) : LineTheme.pressable(getContext())); + setSelected(active); + icon.setIconColor(LineTheme.TEXT_SECONDARY); + labelView.setTextColor(LineTheme.TEXT); labelView.setTypeface(Typeface.create(active ? "sans-serif-medium" : "sans-serif", Typeface.NORMAL)); } diff --git a/app/src/main/java/cn/lineai/ui/component/PromptTemplatesScreenView.java b/app/src/main/java/cn/lineai/ui/component/PromptTemplatesScreenView.java index 74ff68a3..09c832c5 100644 --- a/app/src/main/java/cn/lineai/ui/component/PromptTemplatesScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/PromptTemplatesScreenView.java @@ -73,7 +73,7 @@ private static String variablesText(PromptTemplateItem item) { return builder.toString(); } - private static final class PromptTemplateEditorView extends LinearLayout { + private static final class PromptTemplateEditorView extends ScreenSurfaceView { private final PromptTemplateItem item; private final TextView statusView; private final EditText input; @@ -119,7 +119,7 @@ private static final class PromptTemplateEditorView extends LinearLayout { inputParams.topMargin = LineTheme.dp(context, LineTheme.MD); addView(input, inputParams); - LinearLayout actions = new LinearLayout(context); + LinearLayout actions = new AdaptiveActionsView(context); actions.setOrientation(HORIZONTAL); actions.setGravity(Gravity.CENTER_VERTICAL); LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(context, 34)); diff --git a/app/src/main/java/cn/lineai/ui/component/RefreshCwButtonView.java b/app/src/main/java/cn/lineai/ui/component/RefreshCwButtonView.java index 90e2e2ee..7a3fe87b 100644 --- a/app/src/main/java/cn/lineai/ui/component/RefreshCwButtonView.java +++ b/app/src/main/java/cn/lineai/ui/component/RefreshCwButtonView.java @@ -28,6 +28,12 @@ public RefreshCwButtonView(Context context, int iconDp) { paint.setStrokeWidth(LineTheme.dp(context, 2)); } + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int size = LineTheme.dp(getContext(), ScreenHeaderView.ACTION_SIZE_DP); + setMeasuredDimension(resolveSize(size, widthMeasureSpec), resolveSize(size, heightMeasureSpec)); + } + @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); diff --git a/app/src/main/java/cn/lineai/ui/component/ScreenFactories.java b/app/src/main/java/cn/lineai/ui/component/ScreenFactories.java index 0667dc6d..cb9b8358 100644 --- a/app/src/main/java/cn/lineai/ui/component/ScreenFactories.java +++ b/app/src/main/java/cn/lineai/ui/component/ScreenFactories.java @@ -1274,6 +1274,11 @@ public void onInstallSkillFromGitHub(String location, String githubUrl) { } } + @Override + public void onOpenSkillStore() { + controller.onSettingsItemSelected("skillStore"); + } + @Override public void onEnabledChanged(String kind, String id, boolean enabled) { controller.onExtensionEnabledChanged(kind, id, enabled); @@ -1307,6 +1312,170 @@ public boolean matches(String id) { } } + public static final class SkillStoreScreenFactory implements ScreenFactory { + @Override + public View createScreen(MainChatView view, MainUiController controller, Context context) { + return new SkillStoreScreenView(context, new SkillStoreScreenView.Listener() { + @Override + public void onBack() { + view.handleScreenBack(); + } + + @Override + public void onOpen(String slug) { + controller.onSettingsItemSelected("skillStoreDetail:" + slug); + } + + @Override + public void onLogin() { + controller.onSettingsItemSelected("skillHubLogin"); + } + + @Override + public void onCenter() { + controller.onSettingsItemSelected("skillHubCenter"); + } + + @Override + public void onPublish() { + controller.onSettingsItemSelected("skillHubPublish"); + } + }); + } + + @Override + public String screenId() { + return "skillStore"; + } + } + + public static final class SkillHubCenterScreenFactory implements ScreenFactory { + @Override + public View createScreen(MainChatView view, MainUiController controller, Context context) { + return new SkillHubCenterScreenView(context, new SkillHubCenterScreenView.Listener() { + @Override + public void onBack() { + view.handleScreenBack(); + } + + @Override + public void onOpen(String destination) { + controller.onSettingsItemSelected("skillHubWeb:" + destination); + } + }); + } + + @Override + public String screenId() { + return "skillHubCenter"; + } + } + + public static final class SkillHubWebScreenFactory implements ScreenFactory { + private static final String PREFIX = "skillHubWeb:"; + + @Override + public View createScreen(MainChatView view, MainUiController controller, Context context) { + String id = currentScreenId(view); + return new SkillHubWebScreenView( + context, id.substring(PREFIX.length()), view::handleScreenBack); + } + + @Override + public String screenId() { + return PREFIX; + } + + @Override + public boolean matches(String id) { + return id != null && id.startsWith(PREFIX); + } + } + + public static final class SkillHubPublishScreenFactory implements ScreenFactory { + @Override + public View createScreen(MainChatView view, MainUiController controller, Context context) { + return new SkillHubPublishScreenView( + context, + controller.getExtensionOverview().getSkills(), + new SkillHubPublishScreenView.Listener() { + @Override + public void onBack() { + view.handleScreenBack(); + } + + @Override + public void onPublished() { + view.handleScreenBack(); + } + }); + } + + @Override + public String screenId() { + return "skillHubPublish"; + } + } + + public static final class SkillHubLoginScreenFactory implements ScreenFactory { + @Override + public View createScreen(MainChatView view, MainUiController controller, Context context) { + return new SkillHubLoginScreenView( + context, + view::handleScreenBack, + view::handleScreenBack); + } + + @Override + public String screenId() { + return "skillHubLogin"; + } + } + + public static final class SkillStoreDetailScreenFactory implements ScreenFactory { + private static final String PREFIX = "skillStoreDetail:"; + + @Override + public View createScreen(MainChatView view, MainUiController controller, Context context) { + String id = currentScreenId(view); + String slug = id.substring(PREFIX.length()); + return new SkillStoreDetailScreenView(context, slug, new SkillStoreDetailScreenView.Listener() { + @Override + public void onBack() { + view.handleScreenBack(); + } + + @Override + public void onLogin() { + controller.onSettingsItemSelected("skillHubLogin"); + } + + @Override + public void onOfficial(String namespace, String slug) { + String owner = namespace == null || namespace.length() == 0 + ? "official" : namespace; + controller.onSettingsItemSelected( + "skillHubWeb:skill:" + owner + ":" + slug); + } + + @Override + public void onInstall(String location, String slug, String version) throws Exception { + controller.onSkillInstalledFromSkillHub(location, slug, version); + } + }); + } + + @Override + public String screenId() { + return PREFIX; + } + + @Override + public boolean matches(String id) { + return id != null && id.startsWith(PREFIX); + } + } + // ===== Browser screens ===== public static final class BrowserScreenFactory implements ScreenFactory { diff --git a/app/src/main/java/cn/lineai/ui/component/ScreenHeaderView.java b/app/src/main/java/cn/lineai/ui/component/ScreenHeaderView.java index 7f0d8cc3..60dda794 100644 --- a/app/src/main/java/cn/lineai/ui/component/ScreenHeaderView.java +++ b/app/src/main/java/cn/lineai/ui/component/ScreenHeaderView.java @@ -1,67 +1,66 @@ package cn.lineai.ui.component; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Typeface; import android.view.Gravity; import android.view.View; import android.widget.LinearLayout; import android.widget.TextView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +/** Navigation and page title are separate so actions never squeeze the heading. */ public final class ScreenHeaderView extends LinearLayout { - private final Paint borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - + static final int ACTION_SIZE_DP = 48; + static final int ICON_SIZE_DP = 22; public ScreenHeaderView(Context context, String title, Runnable onBack, View rightAction) { - this(context, title, onBack == null ? null : backButtonView(context, onBack), rightAction); + this(context, title, onBack == null ? null : backButton(context, onBack), rightAction); + } + public ScreenHeaderView(Context context, String title, Runnable onBack, View rightAction, boolean inlineTitle) { + this(context, title, onBack == null ? null : backButton(context, onBack), rightAction, inlineTitle); } - public ScreenHeaderView(Context context, String title, View leftAction, View rightAction) { + this(context, title, leftAction, rightAction, false); + } + private ScreenHeaderView(Context context, String title, View leftAction, View rightAction, boolean inlineTitle) { super(context); - setOrientation(HORIZONTAL); - setGravity(Gravity.CENTER_VERTICAL); + setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); - setWillNotDraw(false); - LineTheme.padding(this, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); - - View left = leftAction == null ? spacer(context) : leftAction; - addView(left, new LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36))); - - TextView titleView = LineTheme.text(context, title, LineTheme.FONT_LG, LineTheme.TEXT, Typeface.BOLD); - titleView.setGravity(Gravity.CENTER); - addView(titleView, new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - - View right = rightAction == null ? spacer(context) : rightAction; - LayoutParams rightParams; - if (rightAction instanceof TextView) { - right.setMinimumWidth(LineTheme.dp(context, 36)); - rightParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 36)); - } else if (rightAction instanceof LinearLayout) { - rightParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + LinearLayout nav = new LinearLayout(context); + nav.setGravity(Gravity.CENTER_VERTICAL); + LineTheme.padding(nav, 4, 4, 8, 0); + if (leftAction != null) addAction(nav, leftAction); + TextView heading = LineTheme.textMedium(context, title, inlineTitle ? 18 : 22, LineTheme.TEXT); + if (android.os.Build.VERSION.SDK_INT >= 28) heading.setAccessibilityHeading(true); + if (inlineTitle) { + heading.setSingleLine(true); + heading.setEllipsize(android.text.TextUtils.TruncateAt.END); + nav.addView(heading, new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); } else { - rightParams = new LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36)); + nav.addView(new View(context), new LayoutParams(0, 1, 1f)); + } + if (rightAction != null) { + addAction(nav, rightAction); + } + addView(nav, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + if (!inlineTitle) { + LineTheme.padding(heading, 16, 8, 16, 14); + addView(heading, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } - addView(right, rightParams); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - borderPaint.setColor(LineTheme.BORDER); - borderPaint.setStrokeWidth(1f); - canvas.drawLine(0, getHeight() - 1, getWidth(), getHeight() - 1, borderPaint); } - - private View spacer(Context context) { - return new View(context); + private void addAction(LinearLayout nav, View action) { + int size = LineTheme.dp(getContext(), ACTION_SIZE_DP); + action.setMinimumHeight(size); + if (action instanceof IconButtonView) { + ((IconButtonView) action).setIconSizeDp(ACTION_SIZE_DP, ICON_SIZE_DP); + nav.addView(action, new LayoutParams(size, size)); + } else { + nav.addView(action, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + } } - - private static View backButtonView(Context context, Runnable onBack) { + private static View backButton(Context context, Runnable onBack) { IconButtonView button = new IconButtonView(context, IconButtonView.CHEVRON_LEFT); button.setIconColor(LineTheme.TEXT); - button.setIconSizeDp(36, 22); + button.setIconSizeDp(ACTION_SIZE_DP, ICON_SIZE_DP); button.setOnClickListener(v -> onBack.run()); return button; } diff --git a/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java b/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java index ff684271..f8238c78 100644 --- a/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java +++ b/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java @@ -6,23 +6,29 @@ import android.widget.LinearLayout; import android.widget.ScrollView; -public class ScreenScaffoldView extends LinearLayout { +public class ScreenScaffoldView extends ScreenSurfaceView { private final LinearLayout content; private final ScrollView scrollView; private final View rightAction; public ScreenScaffoldView(Context context, String title, Runnable onBack, View rightAction) { + this(context, title, onBack, rightAction, false); + } + + protected ScreenScaffoldView(Context context, String title, Runnable onBack, View rightAction, boolean inlineTitle) { super(context); this.rightAction = rightAction; setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); - addView(new ScreenHeaderView(context, title, onBack, rightAction), new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + addView(new ScreenHeaderView(context, title, onBack, rightAction, inlineTitle), new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); scrollView = new ScrollView(context); scrollView.setFillViewport(false); + scrollView.setClipToPadding(false); + scrollView.setVerticalScrollBarEnabled(false); content = new LinearLayout(context); content.setOrientation(VERTICAL); - LineTheme.padding(content, 0, 0, 0, 100); + LineTheme.padding(content, 0, 0, 0, 48); scrollView.addView(content, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); addView(scrollView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); } @@ -38,4 +44,17 @@ public ScrollView getScrollView() { protected View getRightAction() { return rightAction; } + + /** + * Convenience access to string resources for screen subclasses, so screens + * can call {@code getString(R.string.xxx)} instead of + * {@code getContext().getString(...)}. + */ + protected String getString(int resId) { + return getContext().getString(resId); + } + + protected String getString(int resId, Object... formatArgs) { + return getContext().getString(resId, formatArgs); + } } diff --git a/app/src/main/java/cn/lineai/ui/component/ScreenSurfaceView.java b/app/src/main/java/cn/lineai/ui/component/ScreenSurfaceView.java new file mode 100644 index 00000000..0333d7bd --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/ScreenSurfaceView.java @@ -0,0 +1,17 @@ +package cn.lineai.ui.component; + +import android.content.Context; +import android.widget.LinearLayout; +import cn.lineai.ui.theme.LineTheme; + +/** A reading-width page, including in split screen and landscape windows. */ +public class ScreenSurfaceView extends LinearLayout { + public ScreenSurfaceView(Context context) { super(context); } + @Override protected void onMeasure(int widthSpec, int heightSpec) { + int gutter = Math.max(0, (MeasureSpec.getSize(widthSpec) - LineTheme.dp(getContext(), 792)) / 2); + if (getPaddingLeft() != gutter || getPaddingRight() != gutter) { + setPadding(gutter, getPaddingTop(), gutter, getPaddingBottom()); + } + super.onMeasure(widthSpec, heightSpec); + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SectionHeaderView.java b/app/src/main/java/cn/lineai/ui/component/SectionHeaderView.java index 596ac3e0..33df16be 100644 --- a/app/src/main/java/cn/lineai/ui/component/SectionHeaderView.java +++ b/app/src/main/java/cn/lineai/ui/component/SectionHeaderView.java @@ -1,18 +1,16 @@ package cn.lineai.ui.component; -import cn.lineai.ui.theme.LineTheme; - import android.content.Context; import android.widget.TextView; - +import cn.lineai.ui.theme.LineTheme; public final class SectionHeaderView extends TextView { public SectionHeaderView(Context context, String title) { super(context); - setText(title == null ? "" : title.toUpperCase(java.util.Locale.ROOT)); - setTextColor(LineTheme.TEXT_TERTIARY); - setTextSize(LineTheme.FONT_XS); + setText(title == null ? "" : title); + setTextColor(LineTheme.TEXT_SECONDARY); + setTextSize(14); setIncludeFontPadding(false); setTypeface(android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)); - setLetterSpacing(0.05f); - LineTheme.padding(this, LineTheme.LG, 0, LineTheme.LG, 0); + LineTheme.padding(this, 28, 0, 28, 0); + if (android.os.Build.VERSION.SDK_INT >= 28) setAccessibilityHeading(true); } } diff --git a/app/src/main/java/cn/lineai/ui/component/SecuritySettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/SecuritySettingsScreenView.java index b7bb3c85..538fae59 100644 --- a/app/src/main/java/cn/lineai/ui/component/SecuritySettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SecuritySettingsScreenView.java @@ -73,7 +73,7 @@ private void onBypassPathProtectionToggled(Context context, boolean isChecked) { if (bypassPathProtectionSwitch != null) { bypassPathProtectionSwitch.setChecked(false); } - new AlertDialog.Builder(context) + new LineAlertDialog.Builder(context) .setTitle(context.getString(R.string.settings_row_security_bypass_path_warning_title)) .setMessage(context.getString(R.string.settings_row_security_bypass_path_warning_message)) .setNegativeButton(context.getString(R.string.common_cancel), (dialog, which) -> bypassDialogInProgress = false) diff --git a/app/src/main/java/cn/lineai/ui/component/SettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/SettingsScreenView.java index df777495..12b843eb 100644 --- a/app/src/main/java/cn/lineai/ui/component/SettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SettingsScreenView.java @@ -12,7 +12,7 @@ import android.widget.TextView; import cn.lineai.R; -public final class SettingsScreenView extends LinearLayout { +public final class SettingsScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); @@ -33,16 +33,16 @@ public SettingsScreenView(Context context, Listener listener) { scrollView.setFillViewport(false); LinearLayout content = new LinearLayout(context); content.setOrientation(VERTICAL); - LineTheme.padding(content, 0, 0, 0, 100); + LineTheme.padding(content, 0, 0, 0, 48); scrollView.addView(content, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); addView(scrollView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); content.addView(new ActionRowView(context, IconButtonView.SPARKLES, context.getString(R.string.settings_row_tutorial_title), - context.getString(R.string.settings_row_tutorial_desc), + null, false, true, () -> listener.onItem("tutorialFromSettings")), - new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + tutorialParams(context)); addSection(content, context.getString(R.string.screen_settings_section_ai), new RowSpec[] { new RowSpec("models", context.getString(R.string.settings_row_models_title), context.getString(R.string.settings_row_models_desc), IconButtonView.BOX), @@ -74,80 +74,16 @@ public SettingsScreenView(Context context, Listener listener) { }); } - private void addSection(LinearLayout content, String title, RowSpec[] rows) { - Context context = getContext(); - TextView sectionTitle = LineTheme.textMedium(context, title.toUpperCase(java.util.Locale.ROOT), LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY); - sectionTitle.setLetterSpacing(0.05f); - LineTheme.padding(sectionTitle, LineTheme.LG, 0, LineTheme.LG, 0); - LinearLayout.LayoutParams sectionParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - sectionParams.topMargin = LineTheme.dp(context, LineTheme.XL); - sectionParams.bottomMargin = LineTheme.dp(context, LineTheme.MD); - content.addView(sectionTitle, sectionParams); - - LinearLayout group = new LinearLayout(context); - group.setOrientation(VERTICAL); - group.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LinearLayout.LayoutParams groupParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - groupParams.leftMargin = LineTheme.dp(context, LineTheme.LG); - groupParams.rightMargin = LineTheme.dp(context, LineTheme.LG); - content.addView(group, groupParams); - - for (int i = 0; i < rows.length; i++) { - group.addView(rowView(rows[i], i < rows.length - 1), new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } + private LayoutParams tutorialParams(Context context) { + LayoutParams p = new LayoutParams(-1, -2);p.leftMargin=p.rightMargin=LineTheme.dp(context,12);return p; } - - private View rowView(RowSpec row, boolean divider) { - Context context = getContext(); - LinearLayout item = new LinearLayout(context); - item.setOrientation(HORIZONTAL); - item.setGravity(Gravity.CENTER_VERTICAL); - item.setClickable(true); - LineTheme.padding(item, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); - - FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 18)); - IconButtonView icon = new IconButtonView(context, row.icon); - icon.setIconColor(LineTheme.ACCENT); - icon.setIconSizeDp(36, 20); - icon.setClickable(false); - iconWrap.addView(icon, new FrameLayout.LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36), Gravity.CENTER)); - item.addView(iconWrap, new LayoutParams(LineTheme.dp(context, 36), LineTheme.dp(context, 36))); - - LinearLayout labels = new LinearLayout(context); - labels.setOrientation(VERTICAL); - LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - labelParams.leftMargin = LineTheme.dp(context, LineTheme.MD); - labelParams.rightMargin = LineTheme.dp(context, LineTheme.MD); - item.addView(labels, labelParams); - - TextView label = LineTheme.textMedium(context, row.label, LineTheme.FONT_MD, LineTheme.TEXT); - labels.addView(label, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - TextView desc = LineTheme.text(context, row.desc, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, 2); - labels.addView(desc, descParams); - - item.setOnClickListener(v -> listener.onItem(row.id)); - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - chevron.setIconColor(LineTheme.TEXT_TERTIARY); - chevron.setIconSizeDp(20, 16); - chevron.setClickable(false); - item.addView(chevron, new LayoutParams(LineTheme.dp(context, 20), LineTheme.dp(context, 20))); - - if (!divider) { - return item; + private void addSection(LinearLayout content, String title, RowSpec[] rows) { + SettingsSectionView section = new SettingsSectionView(getContext(), title); + for (RowSpec row : rows) { + section.addRow(new ActionRowView(getContext(), row.icon, row.label, null, + false, true, () -> listener.onItem(row.id)), false); } - - LinearLayout wrapper = new LinearLayout(context); - wrapper.setOrientation(VERTICAL); - wrapper.addView(item, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - View line = new View(context); - line.setBackgroundColor(LineTheme.BORDER_LIGHT); - LinearLayout.LayoutParams lineParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 1); - lineParams.leftMargin = LineTheme.dp(context, 68); - wrapper.addView(line, lineParams); - return wrapper; + content.addView(section, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } private static final class RowSpec { diff --git a/app/src/main/java/cn/lineai/ui/component/SettingsSectionView.java b/app/src/main/java/cn/lineai/ui/component/SettingsSectionView.java index 730657f7..2e4a4d3a 100644 --- a/app/src/main/java/cn/lineai/ui/component/SettingsSectionView.java +++ b/app/src/main/java/cn/lineai/ui/component/SettingsSectionView.java @@ -1,68 +1,32 @@ package cn.lineai.ui.component; -import cn.lineai.ui.theme.LineTheme; - import android.content.Context; import android.view.View; +import android.view.ViewGroup; import android.widget.LinearLayout; - +import cn.lineai.ui.theme.LineTheme; public final class SettingsSectionView extends LinearLayout { private final SectionHeaderView header; private final LinearLayout group; - public SettingsSectionView(Context context, String title) { - super(context); - setOrientation(VERTICAL); - + super(context); setOrientation(VERTICAL); header = new SectionHeaderView(context, title); - LinearLayout.LayoutParams headerParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - headerParams.topMargin = LineTheme.dp(context, LineTheme.XL); - headerParams.bottomMargin = LineTheme.dp(context, LineTheme.MD); - addView(header, headerParams); - - group = new LinearLayout(context); - group.setOrientation(VERTICAL); - group.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LinearLayout.LayoutParams groupParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - groupParams.leftMargin = LineTheme.dp(context, LineTheme.LG); - groupParams.rightMargin = LineTheme.dp(context, LineTheme.LG); - addView(group, groupParams); - } - - public void addRow(View row, boolean divider) { - addRow(row, divider, 0); - } - - public void addRow(View row, boolean divider, int dividerInsetDp) { - if (row != null) { - if (row.getParent() instanceof android.view.ViewGroup) { - ((android.view.ViewGroup) row.getParent()).removeView(row); - } - } - if (!divider) { - group.addView(row, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - return; - } - - LinearLayout wrapper = new LinearLayout(getContext()); - wrapper.setOrientation(VERTICAL); - wrapper.addView(row, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - View line = new View(getContext()); - line.setBackgroundColor(LineTheme.BORDER_LIGHT); - LinearLayout.LayoutParams lineParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, 1); - lineParams.leftMargin = LineTheme.dp(getContext(), dividerInsetDp); - wrapper.addView(line, lineParams); - group.addView(wrapper, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - - public LinearLayout getGroup() { - return group; - } - - public void setTitle(String title) { - header.setText((title == null ? "" : title).toUpperCase(java.util.Locale.ROOT)); + LayoutParams hp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + hp.topMargin = LineTheme.dp(context, 28); hp.bottomMargin = LineTheme.dp(context, 8); + addView(header, hp); + group = new LinearLayout(context); group.setOrientation(VERTICAL); + LayoutParams gp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + gp.leftMargin = gp.rightMargin = LineTheme.dp(context, 16); + addView(group, gp); } - - public void removeAllRows() { - group.removeAllViews(); + public void addRow(View row, boolean divider) { addRow(row, divider, 0); } + public void addRow(View row, boolean divider, int inset) { + if (row == null) return; + if (row.getParent() instanceof ViewGroup) ((ViewGroup)row.getParent()).removeView(row); + LayoutParams rowParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + rowParams.topMargin = group.getChildCount() == 0 ? 0 : LineTheme.dp(getContext(), 8); + group.addView(row, rowParams); } + public LinearLayout getGroup() { return group; } + public void setTitle(String title) { header.setText(title == null ? "" : title); } + public void removeAllRows() { group.removeAllViews(); } } diff --git a/app/src/main/java/cn/lineai/ui/component/ShellCommandScreenView.java b/app/src/main/java/cn/lineai/ui/component/ShellCommandScreenView.java index 6fad5e76..50094405 100644 --- a/app/src/main/java/cn/lineai/ui/component/ShellCommandScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ShellCommandScreenView.java @@ -12,7 +12,7 @@ import android.widget.TextView; import cn.lineai.R; -public final class ShellCommandScreenView extends LinearLayout { +public final class ShellCommandScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); } @@ -24,45 +24,12 @@ public ShellCommandScreenView(Context context, String command, Listener listener setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); - LinearLayout header = new LinearLayout(context) { - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - borderPaint.setColor(LineTheme.BORDER_LIGHT); - borderPaint.setStrokeWidth(1f); - canvas.drawLine(0, getHeight() - 1, getWidth(), getHeight() - 1, borderPaint); - } - }; - header.setWillNotDraw(false); - header.setOrientation(HORIZONTAL); - header.setGravity(Gravity.CENTER_VERTICAL); - header.setBackgroundColor(LineTheme.SURFACE_ELEVATED); - LineTheme.padding(header, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); - header.setMinimumHeight(LineTheme.dp(context, 48)); - - LinearLayout back = new LinearLayout(context); - back.setOrientation(HORIZONTAL); - back.setGravity(Gravity.CENTER_VERTICAL); - back.setOnClickListener(v -> listener.onBack()); - IconButtonView chevron = new IconButtonView(context, IconButtonView.CHEVRON_LEFT); - chevron.setIconColor(LineTheme.TEXT); - chevron.setIconSizeDp(22, 22); - chevron.setClickable(false); - back.addView(chevron, new LinearLayout.LayoutParams(LineTheme.dp(context, 22), LineTheme.dp(context, 22))); - TextView exit = LineTheme.text(context, context.getString(R.string.in_app_browser_exit), LineTheme.FONT_MD, LineTheme.TEXT, Typeface.NORMAL); - back.addView(exit, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - header.addView(back, new LinearLayout.LayoutParams(LineTheme.dp(context, 68), LayoutParams.WRAP_CONTENT)); - - TextView title = LineTheme.textMedium(context, context.getString(R.string.shell_command_title), LineTheme.FONT_MD, LineTheme.TEXT); - title.setGravity(Gravity.CENTER); - header.addView(title, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - header.addView(new LinearLayout(context), new LinearLayout.LayoutParams(LineTheme.dp(context, 68), 1)); - addView(header, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + addView(new ScreenHeaderView(context, context.getString(R.string.shell_command_title), listener::onBack, null), new LayoutParams(-1,-2)); ScrollView body = new ScrollView(context); LinearLayout content = new LinearLayout(context); content.setOrientation(VERTICAL); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + LineTheme.padding(content, 28, 8, 28, 48); TextView commandBox = LineTheme.text(context, command == null || command.length() == 0 ? context.getString(R.string.shell_command_empty) : command, LineTheme.FONT_SM, LineTheme.TEXT, Typeface.NORMAL); commandBox.setTypeface(Typeface.MONOSPACE); commandBox.setTextIsSelectable(true); diff --git a/app/src/main/java/cn/lineai/ui/component/SimpleSettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/SimpleSettingsScreenView.java index a9b941f0..4d221ce0 100644 --- a/app/src/main/java/cn/lineai/ui/component/SimpleSettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SimpleSettingsScreenView.java @@ -9,7 +9,7 @@ import android.widget.ScrollView; import android.widget.TextView; -public final class SimpleSettingsScreenView extends LinearLayout { +public final class SimpleSettingsScreenView extends ScreenSurfaceView { public interface Listener { void onBack(); } @@ -37,7 +37,7 @@ public SimpleSettingsScreenView(Context context, String title, String subtitle, LinearLayout group = new LinearLayout(context); group.setOrientation(VERTICAL); - group.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); + group.setBackground(null); content.addView(group, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); for (int i = 0; i < rows.length; i++) { @@ -55,9 +55,6 @@ private View row(Context context, String text, boolean divider) { LineTheme.padding(row, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); TextView label = LineTheme.textMedium(context, text, LineTheme.FONT_MD, LineTheme.TEXT); row.addView(label, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - View dot = new View(context); - dot.setBackground(LineTheme.rounded(context, LineTheme.ACCENT, 4)); - row.addView(dot, new LinearLayout.LayoutParams(LineTheme.dp(context, 8), LineTheme.dp(context, 8))); wrapper.addView(row, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); if (divider) { diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java new file mode 100644 index 00000000..1ee29f3b --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java @@ -0,0 +1,123 @@ +package cn.lineai.ui.component; + +import android.content.Context; +import android.graphics.Typeface; +import android.view.Gravity; +import android.widget.LinearLayout; +import android.widget.TextView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; + +public final class SkillHubCenterScreenView extends ScreenScaffoldView { + public interface Listener { + void onBack(); + void onOpen(String destination); + } + + public SkillHubCenterScreenView(Context context, Listener listener) { + super(context, context.getString(cn.lineai.R.string.skillhub_center_title), listener::onBack, null); + LinearLayout content = getContent(); + LineTheme.padding(content, 28, 8, 28, 48); + + TextView notice = LineTheme.text(context, + context.getString(cn.lineai.R.string.skillhub_center_notice), + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + notice.setBackground(LineTheme.roundedStroke( + context, LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER_LIGHT)); + LineTheme.padding(notice, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + content.addView(notice); + + section(content, context.getString(cn.lineai.R.string.skillhub_section_account_social)); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_profile), + context.getString(cn.lineai.R.string.skillhub_entry_profile_desc), "account", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_my_stars), + context.getString(cn.lineai.R.string.skillhub_entry_my_stars_desc), "stars", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_following), + context.getString(cn.lineai.R.string.skillhub_entry_following_desc), "following", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_notifications), + context.getString(cn.lineai.R.string.skillhub_entry_notifications_desc), "notifications", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_account_settings), + context.getString(cn.lineai.R.string.skillhub_entry_account_settings_desc), "settings", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_verify_identity), + context.getString(cn.lineai.R.string.skillhub_entry_verify_identity_desc), "verify", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_api_token), + context.getString(cn.lineai.R.string.skillhub_entry_api_token_desc), "tokens", listener); + + section(content, context.getString(cn.lineai.R.string.skillhub_section_creator)); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_creator_center), + context.getString(cn.lineai.R.string.skillhub_entry_creator_center_desc), "creator", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_publish_workbench), + context.getString(cn.lineai.R.string.skillhub_entry_publish_workbench_desc), "publish", listener); + + section(content, context.getString(cn.lineai.R.string.skillhub_section_discover)); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_skillset), + context.getString(cn.lineai.R.string.skillhub_entry_skillset_desc), "skillsets", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_mcp_server), + context.getString(cn.lineai.R.string.skillhub_entry_mcp_server_desc), "mcp", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_skill_hunt), + context.getString(cn.lineai.R.string.skillhub_entry_skill_hunt_desc), "skill-hunt", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_contest), + context.getString(cn.lineai.R.string.skillhub_entry_contest_desc), "contest", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_enterprise_square), + context.getString(cn.lineai.R.string.skillhub_entry_enterprise_square_desc), "enterprises", listener); + + section(content, context.getString(cn.lineai.R.string.skillhub_section_enterprise_platform)); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_enterprise_dashboard), + context.getString(cn.lineai.R.string.skillhub_entry_enterprise_dashboard_desc), "enterprise-dashboard", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_enterprise_publish), + context.getString(cn.lineai.R.string.skillhub_entry_enterprise_publish_desc), "enterprise-publish", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_merchant), + context.getString(cn.lineai.R.string.skillhub_entry_merchant_desc), "merchant", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_admin), + context.getString(cn.lineai.R.string.skillhub_entry_admin_desc), "admin", listener); + entry(content, context.getString(cn.lineai.R.string.skillhub_entry_admin_reviews), + context.getString(cn.lineai.R.string.skillhub_entry_admin_reviews_desc), "admin-reviews", listener); + } + + private void section(LinearLayout content, String title) { + TextView value = LineTheme.textMedium(getContext(), title, + LineTheme.FONT_MD, LineTheme.TEXT); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + params.bottomMargin = LineTheme.dp(getContext(), LineTheme.XS); + content.addView(value, params); + } + + private void entry( + LinearLayout content, String title, String description, + String destination, Listener listener) { + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setClickable(true); + row.setFocusable(true); + row.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_ELEVATED, 11)); + LineTheme.padding(row, LineTheme.MD, LineTheme.SM, LineTheme.SM, LineTheme.SM); + + LinearLayout labels = new LinearLayout(getContext()); + labels.setOrientation(VERTICAL); + row.addView(labels, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + labels.addView(LineTheme.textMedium(getContext(), title, LineTheme.FONT_SM, LineTheme.TEXT)); + TextView detail = LineTheme.text(getContext(), description, + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + detailParams.topMargin = LineTheme.dp(getContext(), 2); + labels.addView(detail, detailParams); + + IconButtonView icon = new IconButtonView(getContext(), IconButtonView.CHEVRON_RIGHT); + icon.setIconColor(LineTheme.TEXT_TERTIARY); + icon.setIconSizeDp(26, 16); + icon.setClickable(false); + icon.setFocusable(false); + row.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 26), LineTheme.dp(getContext(), 26))); + row.setOnClickListener(v -> listener.onOpen(destination)); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + content.addView(row, params); + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java new file mode 100644 index 00000000..0cb141cc --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java @@ -0,0 +1,194 @@ +package cn.lineai.ui.component; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import cn.lineai.R; +import cn.lineai.data.service.ContextResourceProvider; +import cn.lineai.data.service.SkillHubSessionClient; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public final class SkillHubLoginScreenView extends ScreenSurfaceView { + private static final String LOGIN_URL = "https://skillhub.cn/"; + private static final Set ALLOWED_HOSTS = new HashSet<>(Arrays.asList( + "skillhub.cn", + "www.skillhub.cn", + "api.skillhub.cn", + "workspace.tencent.com", + "account.tencent.com" + )); + + private static final long SESSION_CHECK_INTERVAL_MS = 1000L; + + private final SkillHubSessionClient sessionClient; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Runnable onLoginComplete; + private final WebView webView; + private final TextView status; + private boolean completing; + private boolean checkInFlight; + private boolean detached; + + public SkillHubLoginScreenView(Context context, Runnable onBack, Runnable onLoginComplete) { + super(context); + this.onLoginComplete = onLoginComplete; + this.sessionClient = new SkillHubSessionClient(new ContextResourceProvider(context)); + setOrientation(VERTICAL); + setBackgroundColor(LineTheme.BG); + addView(new ScreenHeaderView(context, context.getString(R.string.skillhub_official_login), onBack, null), + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + + LinearLayout notice = new LinearLayout(context); + notice.setOrientation(HORIZONTAL); + notice.setGravity(Gravity.CENTER_VERTICAL); + notice.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 10)); + LineTheme.padding(notice, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + IconButtonView shield = new IconButtonView(context, IconButtonView.SHIELD_CHECK); + shield.setIconColor(LineTheme.ACCENT); + shield.setIconSizeDp(28, 17); + shield.setClickable(false); + shield.setFocusable(false); + notice.addView(shield, new LayoutParams(LineTheme.dp(context, 28), LineTheme.dp(context, 28))); + TextView noticeText = LineTheme.text(context, + context.getString(R.string.skillhub_credential_notice), + LineTheme.FONT_XS, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LayoutParams noticeTextParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); + noticeTextParams.leftMargin = LineTheme.dp(context, LineTheme.SM); + notice.addView(noticeText, noticeTextParams); + LayoutParams noticeParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + noticeParams.setMargins(LineTheme.dp(context, LineTheme.LG), LineTheme.dp(context, LineTheme.SM), + LineTheme.dp(context, LineTheme.LG), 0); + addView(notice, noticeParams); + + status = LineTheme.text(context, context.getString(R.string.skillhub_auto_return_notice), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + status.setGravity(Gravity.CENTER); + LayoutParams statusParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + statusParams.setMargins(LineTheme.dp(context, LineTheme.LG), LineTheme.dp(context, LineTheme.SM), + LineTheme.dp(context, LineTheme.LG), LineTheme.dp(context, LineTheme.SM)); + addView(status, statusParams); + + webView = new WebView(context); + webView.setContentDescription(context.getString(R.string.skillhub_login_page_desc)); + harden(webView); + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + cookieManager.setAcceptThirdPartyCookies(webView, false); + } + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return !isAllowed(request == null ? null : request.getUrl()); + } + + @Override + @SuppressWarnings("deprecation") + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return !isAllowed(url == null ? null : Uri.parse(url)); + } + + @Override + public void onPageFinished(WebView view, String url) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + CookieManager.getInstance().flush(); + } + checkLogin(); + } + }); + webView.loadUrl(LOGIN_URL); + addView(webView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); + main.post(sessionCheck); + } + + private final Runnable sessionCheck = new Runnable() { + @Override + public void run() { + checkLogin(); + if (!completing && !detached) { + main.postDelayed(this, SESSION_CHECK_INTERVAL_MS); + } + } + }; + + private void checkLogin() { + if (completing || detached || checkInFlight) { + return; + } + checkInFlight = true; + new Thread(() -> { + try { + SkillHubSessionClient.Session session = sessionClient.currentSession(); + main.post(() -> { + checkInFlight = false; + if (!session.isAuthenticated() || completing || detached) { + return; + } + completing = true; + main.removeCallbacks(sessionCheck); + String name = session.getAccount().getDisplayName(); + status.setText(getContext().getString(R.string.skillhub_logged_in) + + (name.length() == 0 ? "" : ":" + name)); + status.setTextColor(LineTheme.ACCENT); + Toast.makeText(getContext(), getContext().getString(R.string.skillhub_login_success), + Toast.LENGTH_SHORT).show(); + main.postDelayed(onLoginComplete, 150); + }); + } catch (Exception ignored) { + main.post(() -> checkInFlight = false); + } + }, "skillhub-login-check").start(); + } + + @Override + protected void onDetachedFromWindow() { + detached = true; + main.removeCallbacksAndMessages(null); + webView.stopLoading(); + webView.setWebViewClient(null); + webView.destroy(); + super.onDetachedFromWindow(); + } + + @SuppressLint("SetJavaScriptEnabled") + private static void harden(WebView webView) { + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + settings.setSupportMultipleWindows(false); + settings.setJavaScriptCanOpenWindowsAutomatically(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + settings.setAllowFileAccessFromFileURLs(false); + settings.setAllowUniversalAccessFromFileURLs(false); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + } + WebView.setWebContentsDebuggingEnabled(false); + } + + static boolean isAllowed(Uri uri) { + return uri != null + && "https".equalsIgnoreCase(uri.getScheme()) + && ALLOWED_HOSTS.contains(uri.getHost()); + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java new file mode 100644 index 00000000..73c6e8e2 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java @@ -0,0 +1,211 @@ +package cn.lineai.ui.component; + +import android.content.Context; +import android.graphics.Typeface; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import cn.lineai.data.service.ContextResourceProvider; +import cn.lineai.data.service.SkillHubSessionClient; +import cn.lineai.model.SkillRecord; +import cn.lineai.ui.theme.LineTheme; +import java.util.ArrayList; +import java.util.List; + +public final class SkillHubPublishScreenView extends ScreenScaffoldView { + public interface Listener { + void onBack(); + void onPublished(); + } + + private final SkillHubSessionClient client; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Listener listener; + private final List skills = new ArrayList<>(); + private final TextView selected; + private final EditText slug; + private final EditText displayName; + private final EditText version; + private final TextView publish; + private final ProgressBar progress; + private int selectedIndex; + + public SkillHubPublishScreenView( + Context context, List availableSkills, Listener listener) { + super(context, context.getString(cn.lineai.R.string.skillhub_publish_title), listener::onBack, null); + this.listener = listener; + this.client = new SkillHubSessionClient(new ContextResourceProvider(context)); + if (availableSkills != null) { + for (SkillRecord skill : availableSkills) { + if (skill != null && !SkillRecord.LOCATION_SSH.equals(skill.getLocation())) { + skills.add(skill); + } + } + } + + LinearLayout content = getContent(); + LineTheme.padding(content, 28, 8, 28, 48); + addNotice(content); + selected = fieldButton(content); + slug = field(content, context.getString(cn.lineai.R.string.skillhub_slug_label), + context.getString(cn.lineai.R.string.skillhub_slug_hint), false); + displayName = field(content, context.getString(cn.lineai.R.string.skillhub_display_name_label), + context.getString(cn.lineai.R.string.skillhub_display_name_hint), false); + version = field(content, context.getString(cn.lineai.R.string.skillhub_version_label), + context.getString(cn.lineai.R.string.skillhub_version_hint), false); + + publish = LineTheme.textMedium(context, context.getString(cn.lineai.R.string.skillhub_publish_to_hub), + LineTheme.FONT_MD, LineTheme.TEXT_ON_COLOR); + publish.setGravity(Gravity.CENTER); + publish.setClickable(true); + publish.setFocusable(true); + publish.setBackground(LineTheme.rounded(context, LineTheme.ACCENT, 12)); + LineTheme.padding(publish, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + publish.setOnClickListener(v -> publish()); + LinearLayout.LayoutParams publishParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + publishParams.topMargin = LineTheme.dp(context, LineTheme.LG); + content.addView(publish, publishParams); + + progress = new ProgressBar(context); + progress.setVisibility(GONE); + LinearLayout.LayoutParams progressParams = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + progressParams.gravity = Gravity.CENTER_HORIZONTAL; + progressParams.topMargin = LineTheme.dp(context, LineTheme.MD); + content.addView(progress, progressParams); + + if (skills.isEmpty()) { + selected.setText(getContext().getString(cn.lineai.R.string.skillhub_no_publishable_skills)); + selected.setEnabled(false); + publish.setEnabled(false); + publish.setAlpha(0.45f); + } else { + select(0); + } + } + + private void addNotice(LinearLayout content) { + TextView notice = LineTheme.text(getContext(), + getContext().getString(cn.lineai.R.string.skillhub_publish_notice), + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + notice.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER_LIGHT)); + LineTheme.padding(notice, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + content.addView(notice, new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + private TextView fieldButton(LinearLayout content) { + TextView value = LineTheme.textMedium(getContext(), + getContext().getString(cn.lineai.R.string.skillhub_select_local_skill), + LineTheme.FONT_MD, LineTheme.TEXT); + value.setGravity(Gravity.CENTER_VERTICAL); + value.setClickable(true); + value.setFocusable(true); + value.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.INPUT_BG, 12, LineTheme.BORDER_LIGHT)); + LineTheme.padding(value, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + value.setOnClickListener(v -> select((selectedIndex + 1) % skills.size())); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + content.addView(value, params); + return value; + } + + private EditText field( + LinearLayout content, String label, String hint, boolean multiline) { + TextView title = LineTheme.textMedium(getContext(), label, + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + titleParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + content.addView(title, titleParams); + + EditText input = new EditText(getContext()); + input.setHint(hint); + input.setHintTextColor(LineTheme.TEXT_TERTIARY); + input.setTextColor(LineTheme.TEXT); + input.setTextSize(LineTheme.FONT_MD); + input.setSingleLine(!multiline); + input.setInputType(multiline + ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE + : InputType.TYPE_CLASS_TEXT); + input.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.INPUT_BG, 12, LineTheme.BORDER_LIGHT)); + LineTheme.padding(input, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + content.addView(input, new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + return input; + } + + private void select(int index) { + selectedIndex = index; + SkillRecord skill = skills.get(index); + selected.setText(skill.getName() + " · " + skill.getLocationLabel()); + displayName.setText(skill.getName()); + String candidate = skill.getName().trim().toLowerCase() + .replaceAll("[^a-z0-9._-]+", "-") + .replaceAll("^-+|-+$", ""); + slug.setText(candidate); + if (version.getText().length() == 0) { + version.setText("1.0.0"); + } + } + + private void publish() { + if (skills.isEmpty()) { + return; + } + setBusy(true); + SkillRecord skill = skills.get(selectedIndex); + String slugValue = slug.getText().toString(); + String nameValue = displayName.getText().toString(); + String versionValue = version.getText().toString(); + new Thread(() -> { + try { + SkillHubSessionClient.Session session = client.currentSession(); + if (!session.isAuthenticated()) { + throw new IllegalStateException(getContext().getString(cn.lineai.R.string.skillhub_error_not_logged_in)); + } + client.publish(skill, slugValue, nameValue, versionValue); + main.post(() -> { + setBusy(false); + Toast.makeText(getContext(), getContext().getString(cn.lineai.R.string.skillhub_publish_success), Toast.LENGTH_SHORT).show(); + listener.onPublished(); + }); + } catch (Exception e) { + main.post(() -> { + setBusy(false); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-publish").start(); + } + + private void setBusy(boolean busy) { + selected.setEnabled(!busy); + slug.setEnabled(!busy); + displayName.setEnabled(!busy); + version.setEnabled(!busy); + publish.setEnabled(!busy); + publish.setText(busy ? getContext().getString(cn.lineai.R.string.skillhub_publishing) + : getContext().getString(cn.lineai.R.string.skillhub_publish_to_hub)); + progress.setVisibility(busy ? VISIBLE : GONE); + } + + private String safeMessage(Exception error) { + String message = error.getMessage(); + return message == null || message.trim().length() == 0 + ? getContext().getString(cn.lineai.R.string.skillhub_error_publish_failed) + : message; + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java new file mode 100644 index 00000000..273aa66f --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java @@ -0,0 +1,171 @@ +package cn.lineai.ui.component; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.view.Gravity; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public final class SkillHubWebScreenView extends ScreenSurfaceView { + private static final String SITE_ROOT = "https://skillhub.cn"; + private static final Set ALLOWED_HOSTS = new HashSet<>(Arrays.asList( + "skillhub.cn", + "www.skillhub.cn", + "api.skillhub.cn", + "workspace.tencent.com", + "account.tencent.com" + )); + private static Map DESTINATIONS; + + private final WebView webView; + + @SuppressLint("SetJavaScriptEnabled") + public SkillHubWebScreenView(Context context, String destinationId, Runnable onBack) { + super(context); + DESTINATIONS = destinations(context); + Destination destination = requireDestination(destinationId, context); + setOrientation(VERTICAL); + setBackgroundColor(LineTheme.BG); + addView(new ScreenHeaderView(context, destination.title, onBack, null), + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + + LinearLayout notice = new LinearLayout(context); + notice.setOrientation(HORIZONTAL); + notice.setGravity(Gravity.CENTER_VERTICAL); + notice.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 10)); + LineTheme.padding(notice, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + IconButtonView shield = new IconButtonView(context, IconButtonView.SHIELD_CHECK); + shield.setIconColor(LineTheme.ACCENT); + shield.setIconSizeDp(26, 16); + shield.setClickable(false); + shield.setFocusable(false); + notice.addView(shield, new LayoutParams(LineTheme.dp(context, 26), LineTheme.dp(context, 26))); + TextView text = LineTheme.text(context, + context.getString(cn.lineai.R.string.skillhub_official_notice), + LineTheme.FONT_XS, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LayoutParams textParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); + textParams.leftMargin = LineTheme.dp(context, LineTheme.SM); + notice.addView(text, textParams); + LayoutParams noticeParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + noticeParams.setMargins(LineTheme.dp(context, LineTheme.MD), LineTheme.dp(context, LineTheme.SM), + LineTheme.dp(context, LineTheme.MD), LineTheme.dp(context, LineTheme.SM)); + addView(notice, noticeParams); + + CookieManager cookies = CookieManager.getInstance(); + cookies.setAcceptCookie(true); + webView = new WebView(context); + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + settings.setSupportMultipleWindows(false); + settings.setJavaScriptCanOpenWindowsAutomatically(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + cookies.setAcceptThirdPartyCookies(webView, false); + } + WebView.setWebContentsDebuggingEnabled(false); + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return !isAllowed(request.getUrl()); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return !isAllowed(Uri.parse(url)); + } + }); + addView(webView, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); + webView.loadUrl(SITE_ROOT + destination.path); + } + + @Override + protected void onDetachedFromWindow() { + webView.stopLoading(); + webView.setWebViewClient(null); + webView.destroy(); + super.onDetachedFromWindow(); + } + + private static boolean isAllowed(Uri uri) { + return uri != null && "https".equalsIgnoreCase(uri.getScheme()) + && uri.getHost() != null && ALLOWED_HOSTS.contains(uri.getHost().toLowerCase()); + } + + private static Destination requireDestination(String id, Context context) { + Map destinations = destinations(context); + Destination destination = destinations.get(id); + if (destination == null && id != null && id.startsWith("skill:")) { + String[] parts = id.split(":", -1); + if (parts.length == 3 && safeSegment(parts[1]) && safeSegment(parts[2])) { + destination = new Destination(context.getString(cn.lineai.R.string.skillhub_full_detail), + "/skills/" + parts[1] + "/" + parts[2]); + } + } + if (destination == null) { + throw new IllegalArgumentException(context.getString(cn.lineai.R.string.skillhub_invalid_entry)); + } + return destination; + } + + private static boolean safeSegment(String value) { + return value != null && value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + } + + private static Map destinations(Context context) { + HashMap values = new HashMap<>(); + add(values, "account", context.getString(cn.lineai.R.string.skillhub_account), "/dashboard"); + add(values, "settings", context.getString(cn.lineai.R.string.skillhub_settings), "/dashboard/settings"); + add(values, "verify", context.getString(cn.lineai.R.string.skillhub_verify), "/dashboard/verify"); + add(values, "tokens", "API Token", "/dashboard/keys"); + add(values, "stars", context.getString(cn.lineai.R.string.skillhub_stars), "/dashboard/stars"); + add(values, "following", context.getString(cn.lineai.R.string.skillhub_following), "/dashboard/following"); + add(values, "notifications", context.getString(cn.lineai.R.string.skillhub_notifications), "/notifications"); + add(values, "creator", context.getString(cn.lineai.R.string.skillhub_creator), "/dashboard"); + add(values, "publish", context.getString(cn.lineai.R.string.skillhub_publish), "/dashboard/publish"); + add(values, "skillsets", "SkillSet", "/skillspackage"); + add(values, "mcp", "MCP Server", "/mcp"); + add(values, "skill-hunt", "Skill Hunt", "/skill-hunt"); + add(values, "contest", context.getString(cn.lineai.R.string.skillhub_contest), "/contest"); + add(values, "enterprises", context.getString(cn.lineai.R.string.skillhub_enterprises), "/enterprise-zone"); + add(values, "enterprise-dashboard", context.getString(cn.lineai.R.string.skillhub_enterprise_dashboard), "/enterprise/dashboard"); + add(values, "enterprise-publish", context.getString(cn.lineai.R.string.skillhub_enterprise_publish), "/enterprise/dashboard/publish"); + add(values, "merchant", context.getString(cn.lineai.R.string.skillhub_merchant), "/admin/merchant"); + add(values, "admin", context.getString(cn.lineai.R.string.skillhub_admin), "/admin"); + add(values, "admin-reviews", context.getString(cn.lineai.R.string.skillhub_admin_reviews), "/admin/skill-reviews"); + return Collections.unmodifiableMap(values); + } + + private static void add(Map values, String id, String title, String path) { + values.put(id, new Destination(title, path)); + } + + private static final class Destination { + final String title; + final String path; + + Destination(String title, String path) { + this.title = title; + this.path = path; + } + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java b/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java new file mode 100644 index 00000000..c00f4db7 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java @@ -0,0 +1,70 @@ +package cn.lineai.ui.component; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; +import android.util.LruCache; +import android.widget.ImageView; +import cn.lineai.data.service.ContextResourceProvider; +import cn.lineai.data.service.SkillHubClient; + +final class SkillIconLoader { + private static final int ICON_SIZE_PX = 192; + private static final LruCache CACHE = new LruCache<>(32); + private final SkillHubClient client; + private final Handler main = new Handler(Looper.getMainLooper()); + + SkillIconLoader(Context context) { + client = new SkillHubClient(new ContextResourceProvider(context)); + } + + void load(String url, ImageView target) { + String value = url == null ? "" : url.trim(); + target.setTag(value); + if (value.length() == 0) { + return; + } + Bitmap cached = CACHE.get(value); + if (cached != null) { + target.setImageBitmap(cached); + target.clearColorFilter(); + return; + } + new Thread(() -> { + try { + Bitmap bitmap = decode(client.icon(value)); + if (bitmap == null) { + return; + } + CACHE.put(value, bitmap); + main.post(() -> { + if (value.equals(target.getTag())) { + target.setImageBitmap(bitmap); + target.clearColorFilter(); + } + }); + } catch (Exception ignored) { + // 保留项目图标占位,不让单个远程图标影响商店内容。 + } + }, "skillhub-icon").start(); + } + + private static Bitmap decode(byte[] bytes) { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, bounds); + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null; + } + int sampleSize = 1; + while (bounds.outWidth / sampleSize > ICON_SIZE_PX * 2 + || bounds.outHeight / sampleSize > ICON_SIZE_PX * 2) { + sampleSize *= 2; + } + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java new file mode 100644 index 00000000..9b2d0422 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java @@ -0,0 +1,1417 @@ +package cn.lineai.ui.component; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import cn.lineai.R; +import cn.lineai.data.service.ContextResourceProvider; +import cn.lineai.data.service.SkillHubClient; +import cn.lineai.data.service.SkillHubSessionClient; +import cn.lineai.model.SkillHubModels; +import cn.lineai.model.SkillRecord; +import cn.lineai.share.ShareHelper; +import cn.lineai.ui.markdown.MarkdownView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.text.DateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public final class SkillStoreDetailScreenView extends ScreenScaffoldView { + public interface Listener { + void onBack(); + void onLogin(); + void onOfficial(String namespace, String slug); + void onInstall(String location, String slug, String version) throws Exception; + } + + private static final int TAB_OVERVIEW = 0; + private static final int TAB_FILES = 1; + private static final int TAB_COMMENTS = 2; + private static final int TAB_VERSIONS = 3; + private static final int TAB_EVALUATION = 4; + private static final int TAB_PREVIEW = 5; + private static final String READING_PREFERENCES = "skill_store_reading"; + private static final String MARKDOWN_TEXT_SCALE = "markdown_text_scale"; + + private final String slug; + private final Listener listener; + private final SkillHubClient client; + private final SkillHubSessionClient sessionClient; + private final SkillIconLoader iconLoader; + private final Handler main = new Handler(Looper.getMainLooper()); + private final LinearLayout body; + private final ProgressBar progress; + private SkillHubModels.Detail detail; + private Boolean starred; + private boolean starBusy; + private View starButton; + private int activeTab = TAB_OVERVIEW; + + public SkillStoreDetailScreenView(Context context, String slug, Listener listener) { + super(context, context.getString(R.string.skillhub_title_detail), listener::onBack, null); + this.slug = slug; + this.listener = listener; + this.client = new SkillHubClient(new ContextResourceProvider(context)); + this.sessionClient = new SkillHubSessionClient(new ContextResourceProvider(context)); + this.iconLoader = new SkillIconLoader(context); + body = getContent(); + LineTheme.padding(body, 28, 8, 28, 48); + progress = new ProgressBar(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + params.gravity = Gravity.CENTER_HORIZONTAL; + params.topMargin = LineTheme.dp(context, LineTheme.LG); + body.addView(progress, params); + load(); + } + + private void load() { + progress.setVisibility(VISIBLE); + new Thread(() -> { + try { + SkillHubModels.Detail value = client.detail(slug); + main.post(() -> render(value)); + } catch (Exception e) { + main.post(() -> renderError(e)); + } + }, "skillhub-detail").start(); + } + + private void render(SkillHubModels.Detail value) { + detail = value; + body.removeAllViews(); + addHero(value); + addActions(value); + addInstallButton(value); + addTabs(); + addActiveTab(value); + } + + private void addHero(SkillHubModels.Detail value) { + LinearLayout hero = new LinearLayout(getContext()); + hero.setOrientation(VERTICAL); + hero.setBackground(null); + LineTheme.padding(hero, 0, 8, 0, 24); + + LinearLayout top = new LinearLayout(getContext()); + top.setOrientation(HORIZONTAL); + top.setGravity(Gravity.CENTER_VERTICAL); + IconButtonView icon = new IconButtonView(getContext(), IconButtonView.PACKAGE); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(64, 32); + icon.setClickable(false); + icon.setFocusable(false); + icon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 14)); + top.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 64), LineTheme.dp(getContext(), 64))); + iconLoader.load(value.getIconUrl(), icon); + + LinearLayout copy = new LinearLayout(getContext()); + copy.setOrientation(VERTICAL); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + copyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.LG); + top.addView(copy, copyParams); + + LinearLayout titleRow = new LinearLayout(getContext()); + titleRow.setOrientation(HORIZONTAL); + titleRow.setGravity(Gravity.CENTER_VERTICAL); + TextView title = LineTheme.textMedium(getContext(), value.getName(), + LineTheme.FONT_XL, LineTheme.TEXT); + title.setMaxLines(2); + titleRow.addView(title, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + if (value.isVerified()) { + TextView verified = tag(getString(R.string.skillhub_verified), LineTheme.ACCENT, LineTheme.ACCENT_MUTED); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + params.leftMargin = LineTheme.dp(getContext(), LineTheme.XS); + titleRow.addView(verified, params); + } + copy.addView(titleRow); + + String identity = value.getCanonicalName().length() > 0 + ? value.getCanonicalName() : value.getOwner(); + if (value.getPublisher().length() > 0) { + identity += (identity.length() == 0 ? "" : " · ") + value.getPublisher(); + } + if (identity.length() > 0) { + TextView identityView = LineTheme.text(getContext(), identity, + LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + copy.addView(identityView, params); + } + hero.addView(top); + + TextView description = LineTheme.text(getContext(), value.getDescription(), + LineTheme.FONT_MD, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + description.setLineSpacing(LineTheme.dp(getContext(), 4), 1f); + LinearLayout.LayoutParams descriptionParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + descriptionParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + hero.addView(description, descriptionParams); + + LinearLayout badges = new LinearLayout(getContext()); + badges.setOrientation(HORIZONTAL); + badges.setGravity(Gravity.CENTER_VERTICAL); + badges.addView(tag("↓ " + formatCount(value.getDownloads()), + LineTheme.TEXT_SECONDARY, LineTheme.SURFACE_LIGHT)); + addBadge(badges, "★ " + formatCount(value.getStars()), LineTheme.TEXT_SECONDARY); + addBadge(badges, "v" + value.getVersion(), LineTheme.TEXT_SECONDARY); + if ("benign".equalsIgnoreCase(value.getSecurityStatus())) { + addBadge(badges, getString(R.string.skillhub_safe), LineTheme.ACCENT); + } + LinearLayout.LayoutParams badgeParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + badgeParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + hero.addView(badges, badgeParams); + body.addView(hero); + } + + private void addActions(SkillHubModels.Detail value) { + LinearLayout actions = new AdaptiveActionsView(getContext()); + actions.setOrientation(HORIZONTAL); + actions.setGravity(Gravity.CENTER_VERTICAL); + actions.addView(actionButton(IconButtonView.COPY, getString(R.string.skillhub_copy_prompt), + () -> copyPrompt(value)), + new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + LinearLayout.LayoutParams starParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + starParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + String starLabel = Boolean.TRUE.equals(starred) + ? getString(R.string.skillhub_unstar) : getString(R.string.skillhub_star); + starButton = actionButton(IconButtonView.SAVE, starLabel, () -> toggleStar(value)); + starButton.setEnabled(!starBusy); + starButton.setAlpha(starBusy ? 0.55f : 1f); + actions.addView(starButton, starParams); + if (starred == null && !starBusy) { + loadStarred(value); + } + LinearLayout.LayoutParams shareParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + shareParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + actions.addView(actionButton(IconButtonView.SHARE, + getString(R.string.skillhub_share), () -> share(value)), shareParams); + LinearLayout.LayoutParams officialParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + officialParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + actions.addView(actionButton(IconButtonView.EXTERNAL_LINK, + getString(R.string.skillhub_full_features), + () -> listener.onOfficial(namespaceHandle(value), value.getSlug())), officialParams); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + body.addView(actions, params); + } + + private View actionButton(int iconType, String label, Runnable action) { + LinearLayout button = new LinearLayout(getContext()); + button.setOrientation(HORIZONTAL); + button.setGravity(Gravity.CENTER); + button.setClickable(true); + button.setFocusable(true); + button.setContentDescription(label); + button.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 10, LineTheme.BORDER_LIGHT)); + LineTheme.padding(button, LineTheme.SM, LineTheme.SM, LineTheme.SM, LineTheme.SM); + IconButtonView icon = new IconButtonView(getContext(), iconType); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(20, 16); + icon.setClickable(false); + icon.setFocusable(false); + button.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 20), LineTheme.dp(getContext(), 20))); + TextView text = LineTheme.textMedium(getContext(), label, LineTheme.FONT_SM, LineTheme.TEXT); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + textParams.leftMargin = LineTheme.dp(getContext(), LineTheme.XS); + button.addView(text, textParams); + button.setOnClickListener(v -> action.run()); + return button; + } + + private void addTabs() { + HorizontalScrollView scroll = new HorizontalScrollView(getContext()); + scroll.setHorizontalScrollBarEnabled(false); + LinearLayout tabs = new LinearLayout(getContext()); + tabs.setOrientation(HORIZONTAL); + tabs.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_LIGHT, 10)); + LineTheme.padding(tabs, LineTheme.XS, LineTheme.XS, LineTheme.XS, LineTheme.XS); + addTab(tabs, TAB_OVERVIEW, getString(R.string.skillhub_tab_overview)); + addTab(tabs, TAB_FILES, getString(R.string.skillhub_tab_files, detail.getFiles().size())); + addTab(tabs, TAB_COMMENTS, getString(R.string.skillhub_tab_comments, detail.getComments().size())); + addTab(tabs, TAB_VERSIONS, getString(R.string.skillhub_tab_versions)); + addTab(tabs, TAB_EVALUATION, getString(R.string.skillhub_tab_evaluation)); + addTab(tabs, TAB_PREVIEW, getString(R.string.skillhub_tab_preview)); + scroll.addView(tabs, new HorizontalScrollView.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + body.addView(scroll, params); + } + + private void addTab(LinearLayout tabs, int tabId, String label) { + boolean active = activeTab == tabId; + TextView tab = active + ? LineTheme.textMedium(getContext(), label, LineTheme.FONT_SM, LineTheme.ACCENT) + : LineTheme.text(getContext(), label, LineTheme.FONT_SM, + LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + tab.setGravity(Gravity.CENTER); + tab.setClickable(true); + tab.setFocusable(true); + tab.setBackground(active + ? LineTheme.rounded(getContext(), LineTheme.SURFACE_ELEVATED, 7) : null); + LineTheme.padding(tab, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + tab.setOnClickListener(v -> { + if (activeTab != tabId) { + activeTab = tabId; + render(detail); + } + }); + tabs.addView(tab); + } + + private void addActiveTab(SkillHubModels.Detail value) { + switch (activeTab) { + case TAB_FILES: + addFiles(value); + break; + case TAB_COMMENTS: + addComments(value); + break; + case TAB_VERSIONS: + addVersions(value); + break; + case TAB_EVALUATION: + addEvaluation(value); + break; + case TAB_PREVIEW: + addPreview(value); + break; + default: + addOverview(value); + break; + } + } + + private void addOverview(SkillHubModels.Detail value) { + LinearLayout meta = section(getString(R.string.skillhub_skill_info), IconButtonView.BOXES); + LinearLayout primary = new LinearLayout(getContext()); + primary.setOrientation(HORIZONTAL); + primary.addView(metadataCard(getString(R.string.skillhub_category), value.getCategory()), + new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + LinearLayout.LayoutParams sourceParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + sourceParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + primary.addView(metadataCard(getString(R.string.skillhub_source), sourceName(value.getSource())), sourceParams); + addSectionContent(meta, primary); + + LinearLayout secondary = new LinearLayout(getContext()); + secondary.setOrientation(HORIZONTAL); + secondary.addView(metadataCard(getString(R.string.skillhub_tab_versions), "v" + value.getVersion()), + new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + LinearLayout.LayoutParams dateParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + dateParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + secondary.addView(metadataCard(getString(R.string.skillhub_updated), formatDate(value.getUpdatedAt())), dateParams); + addSectionContent(meta, secondary); + if (!value.getSubCategories().isEmpty()) { + addSectionContent(meta, metadataRow(getString(R.string.skillhub_subcategories), join(value.getSubCategories()))); + } + if (!value.getTags().isEmpty()) { + addSectionContent(meta, metadataRow(getString(R.string.skillhub_tags), join(value.getTags()))); + } + addSection(meta); + + addSecurity(value); + LinearLayout content = section(getString(R.string.skillhub_skill_md), IconButtonView.FILE_TEXT); + TextView zoomHint = LineTheme.text(getContext(), + getString(R.string.skillhub_pinch_to_zoom), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + addSectionContent(content, zoomHint); + if (value.getMarkdown().trim().length() == 0) { + addSectionContent(content, emptyText(getString(R.string.skillhub_no_public_doc))); + } else { + LinearLayout reader = new LinearLayout(getContext()); + reader.setOrientation(VERTICAL); + reader.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_LIGHT, 10)); + LineTheme.padding(reader, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + MarkdownView markdown = new MarkdownView(getContext()); + markdown.setCodeWrapEnabled(true); + configureReadingScale(markdown); + markdown.setLinkHandler(url -> openHttps(url)); + markdown.setMarkdown(value.getMarkdown()); + reader.addView(markdown, new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + addSectionContent(content, reader); + } + addSection(content); + } + + private void addSecurity(SkillHubModels.Detail value) { + boolean unsafe = value.hasScripts() + || (!value.getSecurityStatus().isEmpty() + && !"benign".equalsIgnoreCase(value.getSecurityStatus())); + LinearLayout section = section(getString(R.string.skillhub_safety_check), + unsafe ? IconButtonView.CIRCLE_ALERT : IconButtonView.SHIELD_CHECK); + String text = value.getSecurityStatusText().length() > 0 + ? value.getSecurityStatusText() + : unsafe ? getString(R.string.skillhub_check_before_use) + : getString(R.string.skillhub_no_obvious_risk); + if (value.hasScripts()) { + text += "\n" + getString(R.string.skillhub_contains_scripts); + } + if (value.requiresApiKey()) { + text += "\n" + getString(R.string.skillhub_requires_api_key_warning); + } + addSectionContent(section, LineTheme.text(getContext(), text, + LineTheme.FONT_SM, unsafe ? LineTheme.WARNING : LineTheme.TEXT_SECONDARY, + Typeface.NORMAL)); + addSection(section); + } + + private void addFiles(SkillHubModels.Detail value) { + LinearLayout section = section(getString(R.string.skillhub_file_list, value.getFiles().size()), + IconButtonView.FILE_TEXT); + addSectionContent(section, LineTheme.text(getContext(), + getString(R.string.skillhub_click_to_preview), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL)); + if (value.getFiles().isEmpty()) { + addSectionContent(section, emptyText(getString(R.string.skillhub_no_public_files))); + } else { + for (SkillHubModels.FileEntry file : value.getFiles()) { + addSectionContent(section, fileRow(value, file)); + } + } + addSection(section); + } + + private View fileRow(SkillHubModels.Detail value, SkillHubModels.FileEntry file) { + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setClickable(true); + row.setFocusable(true); + row.setContentDescription(getString(R.string.skillhub_preview_file, file.getPath())); + row.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_LIGHT, 9)); + LineTheme.padding(row, LineTheme.MD, LineTheme.SM, LineTheme.SM, LineTheme.SM); + + IconButtonView icon = new IconButtonView(getContext(), fileIcon(file.getPath())); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(32, 18); + icon.setClickable(false); + icon.setFocusable(false); + row.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 32), LineTheme.dp(getContext(), 32))); + + LinearLayout copy = new LinearLayout(getContext()); + copy.setOrientation(VERTICAL); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + copyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + row.addView(copy, copyParams); + String path = file.getPath(); + int slash = path.lastIndexOf('/'); + String name = slash < 0 ? path : path.substring(slash + 1); + String directory = slash < 0 ? getString(R.string.skillhub_root_dir) : path.substring(0, slash); + copy.addView(LineTheme.textMedium(getContext(), name, LineTheme.FONT_SM, LineTheme.TEXT)); + TextView meta = LineTheme.text(getContext(), directory + " · " + formatBytes(file.getSize()), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams metaParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + metaParams.topMargin = LineTheme.dp(getContext(), 2); + copy.addView(meta, metaParams); + + IconButtonView chevron = new IconButtonView(getContext(), IconButtonView.CHEVRON_RIGHT); + chevron.setIconColor(LineTheme.TEXT_TERTIARY); + chevron.setIconSizeDp(24, 16); + chevron.setClickable(false); + chevron.setFocusable(false); + row.addView(chevron, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); + row.setOnClickListener(v -> previewFile(value, file, row)); + return row; + } + + private int fileIcon(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".md") || lower.endsWith(".txt")) { + return IconButtonView.FILE_TEXT; + } + if (lower.endsWith(".json") || lower.endsWith(".js") || lower.endsWith(".java") + || lower.endsWith(".py") || lower.endsWith(".sh") || lower.endsWith(".xml") + || lower.endsWith(".yml") || lower.endsWith(".yaml")) { + return IconButtonView.FILE_CODE; + } + return IconButtonView.FILE; + } + + private void previewFile(SkillHubModels.Detail value, SkillHubModels.FileEntry file, View row) { + if (!isPreviewable(file.getPath())) { + Toast.makeText(getContext(), getString(R.string.skillhub_file_not_supported), Toast.LENGTH_SHORT).show(); + return; + } + row.setEnabled(false); + Toast.makeText(getContext(), getString(R.string.skillhub_loading_file), Toast.LENGTH_SHORT).show(); + new Thread(() -> { + try { + String content = client.fileContent(value.getSlug(), value.getVersion(), file.getPath()); + main.post(() -> { + row.setEnabled(true); + showFilePreview(file.getPath(), content); + }); + } catch (Exception e) { + main.post(() -> { + row.setEnabled(true); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-file-preview").start(); + } + + private boolean isPreviewable(String path) { + String lower = path.toLowerCase(Locale.ROOT); + return lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".json") + || lower.endsWith(".js") || lower.endsWith(".ts") || lower.endsWith(".java") + || lower.endsWith(".py") || lower.endsWith(".sh") || lower.endsWith(".xml") + || lower.endsWith(".yml") || lower.endsWith(".yaml") || lower.endsWith(".toml") + || lower.endsWith(".ini") || lower.endsWith(".properties") || lower.endsWith(".csv"); + } + + private void showFilePreview(String path, String content) { + LinearLayout host = new LinearLayout(getContext()); + host.setOrientation(VERTICAL); + int padding = LineTheme.dp(getContext(), LineTheme.LG); + host.setPadding(padding, LineTheme.dp(getContext(), LineTheme.SM), + padding, LineTheme.dp(getContext(), LineTheme.SM)); + if (path.toLowerCase(Locale.ROOT).endsWith(".md")) { + host.addView(LineTheme.text(getContext(), getString(R.string.skillhub_pinch_to_zoom), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL)); + MarkdownView markdown = new MarkdownView(getContext()); + markdown.setCodeWrapEnabled(true); + configureReadingScale(markdown); + markdown.setLinkHandler(url -> openHttps(url)); + markdown.setMarkdown(content); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + host.addView(markdown, params); + } else { + TextView text = LineTheme.text(getContext(), content, LineTheme.FONT_SM, + LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + text.setTypeface(Typeface.MONOSPACE); + text.setTextIsSelectable(true); + host.addView(text); + } + new LineAlertDialog.Builder(getContext()) + .setTitle(path) + .setView(host) + .setNegativeButton(getString(R.string.skillhub_close), null) + .setPositiveButton(getString(R.string.skillhub_copy), (dialog, which) -> { + ShareHelper.copy(getContext(), content); + Toast.makeText(getContext(), + getString(R.string.skillhub_file_copied), Toast.LENGTH_SHORT).show(); + }) + .show(); + } + + private void addComments(SkillHubModels.Detail value) { + LinearLayout section = section(getString(R.string.skillhub_community_comments), + IconButtonView.MESSAGE_CIRCLE); + TextView compose = LineTheme.textMedium(getContext(), + getString(R.string.skillhub_post_comment), + LineTheme.FONT_SM, LineTheme.TEXT_ON_COLOR); + compose.setGravity(Gravity.CENTER); + compose.setClickable(true); + compose.setFocusable(true); + compose.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT, 9)); + LineTheme.padding(compose, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + compose.setOnClickListener(v -> checkSessionForComment(value, null)); + addSectionContent(section, compose); + if (value.getComments().isEmpty()) { + addSectionContent(section, emptyText(getString(R.string.skillhub_no_comments_login_first))); + } else { + for (SkillHubModels.Comment comment : value.getComments()) { + addComment(section, comment, 0); + } + } + addSection(section); + } + + private void checkSessionForComment( + SkillHubModels.Detail value, SkillHubModels.Comment parent) { + new Thread(() -> { + try { + SkillHubSessionClient.Session session = sessionClient.currentSession(); + main.post(() -> { + if (session.isAuthenticated()) { + showCommentDialog(value, parent); + } else { + Toast.makeText(getContext(), + getString(R.string.skillhub_login_to_star), + Toast.LENGTH_SHORT).show(); + listener.onLogin(); + } + }); + } catch (Exception e) { + main.post(() -> Toast.makeText( + getContext(), safeMessage(e), Toast.LENGTH_LONG).show()); + } + }, "skillhub-comment-session").start(); + } + + private void showCommentDialog( + SkillHubModels.Detail value, SkillHubModels.Comment parent) { + LinearLayout panel = new LinearLayout(getContext()); + panel.setOrientation(VERTICAL); + panel.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 16, LineTheme.BORDER_LIGHT)); + LineTheme.padding(panel, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + String dialogTitle = parent == null ? getString(R.string.skillhub_post_comment) + : getString(R.string.skillhub_reply_to, + parent.getAuthor().length() == 0 ? getString(R.string.skillhub_user) + : parent.getAuthor()); + panel.addView(LineTheme.textMedium(getContext(), dialogTitle, + LineTheme.FONT_LG, LineTheme.TEXT)); + TextView hint = LineTheme.text(getContext(), + getString(R.string.skillhub_comment_review_notice), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams hintParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + hintParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + panel.addView(hint, hintParams); + + EditText input = new EditText(getContext()); + input.setHint(parent == null ? getString(R.string.skillhub_share_experience_hint) + : getString(R.string.skillhub_write_reply_hint)); + input.setHintTextColor(LineTheme.TEXT_TERTIARY); + input.setTextColor(LineTheme.TEXT); + input.setTextSize(LineTheme.FONT_MD); + input.setGravity(Gravity.TOP | Gravity.START); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE + | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); + input.setMinLines(4); + input.setMaxLines(8); + input.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.INPUT_BG, 10, LineTheme.BORDER_LIGHT)); + LineTheme.padding(input, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + inputParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + panel.addView(input, inputParams); + + LinearLayout actions = new AdaptiveActionsView(getContext()); + actions.setOrientation(HORIZONTAL); + TextView cancel = dialogButton(getString(R.string.skillhub_cancel), false); + TextView submit = dialogButton(getString(R.string.skillhub_submit_comment), true); + actions.addView(cancel, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + LinearLayout.LayoutParams submitParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + submitParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + actions.addView(submit, submitParams); + LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + actionsParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + panel.addView(actions, actionsParams); + + Dialog dialog = DialogBuilder.create(getContext()); + cancel.setOnClickListener(v -> dialog.dismiss()); + submit.setOnClickListener(v -> { + String content = input.getText().toString().trim(); + if (content.length() == 0 || content.codePointCount(0, content.length()) > 500) { + Toast.makeText(getContext(), + getString(R.string.skillhub_error_comment_length), Toast.LENGTH_SHORT).show(); + return; + } + submit.setEnabled(false); + cancel.setEnabled(false); + input.setEnabled(false); + submit.setText(getString(R.string.skillhub_submitting)); + new Thread(() -> { + try { + if (parent == null) { + sessionClient.postComment( + value.getSlug(), namespaceHandle(value), content); + } else { + sessionClient.postCommentReply( + value.getSlug(), parent.getId(), namespaceHandle(value), content); + } + main.post(() -> { + dialog.dismiss(); + activeTab = TAB_COMMENTS; + Toast.makeText(getContext(), + getString(R.string.skillhub_comment_submitted), + Toast.LENGTH_LONG).show(); + load(); + }); + } catch (Exception e) { + main.post(() -> { + submit.setEnabled(true); + cancel.setEnabled(true); + input.setEnabled(true); + submit.setText(getString(R.string.skillhub_submit_comment)); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-comment-submit").start(); + }); + DialogBuilder.showInset(dialog, panel); + } + + private void addComment(LinearLayout section, SkillHubModels.Comment comment, int depth) { + LinearLayout card = new LinearLayout(getContext()); + card.setOrientation(VERTICAL); + card.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_LIGHT, 8)); + LineTheme.padding(card, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + + String header = (comment.getAuthor().length() == 0 + ? getString(R.string.skillhub_user) : comment.getAuthor()) + + " · " + formatDate(comment.getCreatedAt()); + card.addView(LineTheme.textMedium(getContext(), header, + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY)); + TextView content = LineTheme.text(getContext(), comment.getContent(), LineTheme.FONT_SM, + depth == 0 ? LineTheme.TEXT_SECONDARY : LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + contentParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + card.addView(content, contentParams); + + LinearLayout actions = new AdaptiveActionsView(getContext()); + actions.setOrientation(HORIZONTAL); + TextView like = commentAction( + (comment.isLiked() ? getString(R.string.skillhub_unlike) + : getString(R.string.skillhub_like)) + + (comment.getLikeCount() > 0 ? " " + comment.getLikeCount() : "")); + like.setOnClickListener(v -> updateCommentLike(comment, !comment.isLiked(), like)); + actions.addView(like); + TextView reply = commentAction(getString(R.string.skillhub_reply)); + reply.setOnClickListener(v -> checkSessionForComment(detail, comment)); + actions.addView(reply); + if (comment.getReplyCount() > comment.getReplies().size()) { + TextView allReplies = commentAction( + getString(R.string.skillhub_all_replies, comment.getReplyCount())); + allReplies.setOnClickListener(v -> loadCommentReplies(section, comment, allReplies)); + actions.addView(allReplies); + } + TextView delete = commentAction(getString(R.string.skillhub_delete)); + delete.setTextColor(LineTheme.DANGER); + delete.setOnClickListener(v -> deleteComment(comment, delete)); + actions.addView(delete); + LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + actionParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + card.addView(actions, actionParams); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + params.leftMargin = LineTheme.dp(getContext(), depth * LineTheme.LG); + section.addView(card, params); + for (SkillHubModels.Comment child : comment.getReplies()) { + addComment(section, child, depth + 1); + } + } + + private TextView commentAction(String label) { + TextView action = LineTheme.textMedium(getContext(), label, + LineTheme.FONT_XS, LineTheme.ACCENT); + action.setClickable(true); + action.setFocusable(true); + LineTheme.padding(action, 0, LineTheme.XS, LineTheme.MD, LineTheme.XS); + return action; + } + + private void updateCommentLike( + SkillHubModels.Comment comment, boolean liked, TextView action) { + action.setEnabled(false); + new Thread(() -> { + try { + SkillHubSessionClient.Session session = sessionClient.currentSession(); + if (!session.isAuthenticated()) { + main.post(() -> { + action.setEnabled(true); + listener.onLogin(); + }); + return; + } + sessionClient.setCommentLiked( + detail.getSlug(), comment.getId(), namespaceHandle(detail), liked); + main.post(() -> { + activeTab = TAB_COMMENTS; + load(); + }); + } catch (Exception e) { + main.post(() -> { + action.setEnabled(true); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-comment-like").start(); + } + + private void deleteComment(SkillHubModels.Comment comment, TextView action) { + action.setEnabled(false); + new Thread(() -> { + try { + sessionClient.deleteComment( + detail.getSlug(), comment.getId(), namespaceHandle(detail)); + main.post(() -> { + activeTab = TAB_COMMENTS; + load(); + }); + } catch (Exception e) { + main.post(() -> { + action.setEnabled(true); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-comment-delete").start(); + } + + private void loadCommentReplies( + LinearLayout section, SkillHubModels.Comment comment, TextView action) { + action.setEnabled(false); + new Thread(() -> { + try { + List replies = client.commentReplies( + detail.getSlug(), comment.getId(), namespaceHandle(detail)); + main.post(() -> { + action.setVisibility(GONE); + for (SkillHubModels.Comment reply : replies) { + addComment(section, reply, 1); + } + }); + } catch (Exception e) { + main.post(() -> { + action.setEnabled(true); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-comment-replies").start(); + } + + private void addVersions(SkillHubModels.Detail value) { + LinearLayout section = section(getString(R.string.skillhub_version_history), + IconButtonView.CLOCK_3); + if (value.getVersions().isEmpty()) { + addSectionContent(section, emptyText(getString(R.string.skillhub_no_version_history))); + } else { + for (SkillHubModels.Version version : value.getVersions()) { + String text = "v" + version.getVersion() + " · " + formatDate(version.getCreatedAt()); + if (version.getSecurityStatusText().length() > 0) { + text += "\n" + version.getSecurityStatusText(); + } + if (version.getChangelog().length() > 0) { + text += "\n" + version.getChangelog(); + } + addSectionContent(section, LineTheme.text(getContext(), text, + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY, Typeface.NORMAL)); + } + } + addSection(section); + } + + private void addEvaluation(SkillHubModels.Detail value) { + LinearLayout section = section(getString(R.string.skillhub_evaluation_report), + IconButtonView.FLASK_CONICAL); + SkillHubModels.Evaluation evaluation = value.getEvaluation(); + if (evaluation == null || evaluation.getStatus().length() == 0) { + addSectionContent(section, emptyText(getString(R.string.skillhub_no_evaluation))); + } else { + if (evaluation.getScore() > 0) { + addSectionContent(section, LineTheme.textMedium(getContext(), + getString(R.string.skillhub_overall_score, evaluation.getScore()), + LineTheme.FONT_XL, LineTheme.ACCENT)); + } + addSectionContent(section, LineTheme.text(getContext(), evaluation.getSummary(), + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY, Typeface.NORMAL)); + int visible = Math.min(5, evaluation.getHighlights().size()); + for (int i = 0; i < visible; i++) { + addSectionContent(section, LineTheme.text(getContext(), + "• " + evaluation.getHighlights().get(i), LineTheme.FONT_SM, + LineTheme.TEXT_TERTIARY, Typeface.NORMAL)); + } + } + addSection(section); + } + + private void addPreview(SkillHubModels.Detail value) { + LinearLayout section = section(getString(R.string.skillhub_tab_preview), IconButtonView.PLAY); + if (value.getTestCases().isEmpty()) { + addSectionContent(section, emptyText(getString(R.string.skillhub_no_preview))); + } else { + for (SkillHubModels.TestCase testCase : value.getTestCases()) { + TextView prompt = LineTheme.textMedium(getContext(), + testCase.getTitle() + "\n" + getString(R.string.skillhub_user_colon) + testCase.getPrompt(), + LineTheme.FONT_SM, LineTheme.TEXT); + prompt.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 8)); + LineTheme.padding(prompt, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + addSectionContent(section, prompt); + + MarkdownView answer = new MarkdownView(getContext()); + answer.setCodeWrapEnabled(true); + answer.setMarkdown(testCase.getExpected()); + addSectionContent(section, answer); + } + } + addSection(section); + } + + private View metadataCard(String label, String value) { + LinearLayout card = new LinearLayout(getContext()); + card.setOrientation(VERTICAL); + card.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_LIGHT, 8)); + LineTheme.padding(card, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + card.addView(LineTheme.text(getContext(), label, LineTheme.FONT_XS, + LineTheme.TEXT_TERTIARY, Typeface.NORMAL)); + TextView content = LineTheme.textMedium(getContext(), + value.length() == 0 ? getString(R.string.skillhub_dash) : value, LineTheme.FONT_SM, LineTheme.TEXT); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), 3); + card.addView(content, params); + return card; + } + + private TextView metadataRow(String label, String value) { + TextView row = LineTheme.text(getContext(), label + " · " + value, + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + row.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_LIGHT, 8)); + LineTheme.padding(row, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + return row; + } + + private LinearLayout section(String title, int iconType) { + LinearLayout section = new LinearLayout(getContext()); + section.setOrientation(VERTICAL); + section.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); + LineTheme.padding(section, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); + LinearLayout header = new LinearLayout(getContext()); + header.setOrientation(HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + IconButtonView icon = new IconButtonView(getContext(), iconType); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(28, 17); + icon.setClickable(false); + icon.setFocusable(false); + header.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 28), LineTheme.dp(getContext(), 28))); + TextView titleView = LineTheme.textMedium(getContext(), title, + LineTheme.FONT_MD, LineTheme.TEXT); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + titleParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + header.addView(titleView, titleParams); + section.addView(header); + return section; + } + + private void addSectionContent(LinearLayout section, View content) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + section.addView(content, params); + } + + private void addSection(LinearLayout section) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + body.addView(section, params); + } + + private void addInstallButton(SkillHubModels.Detail value) { + TextView install = LineTheme.textMedium(getContext(), + getString(R.string.skillhub_select_location_install), + LineTheme.FONT_MD, LineTheme.TEXT_ON_COLOR); + install.setGravity(Gravity.CENTER); + install.setClickable(true); + install.setFocusable(true); + install.setContentDescription(getString(R.string.skillhub_install_skill, value.getName())); + install.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT, 12)); + LineTheme.padding(install, LineTheme.LG, LineTheme.MD, LineTheme.LG, LineTheme.MD); + install.setOnClickListener(v -> showInstallConfirm(value, install)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + body.addView(install, params); + } + + private TextView tag(String value, int color, int background) { + TextView tag = LineTheme.textMedium(getContext(), value, LineTheme.FONT_XS, color); + tag.setSingleLine(true); + tag.setBackground(LineTheme.rounded(getContext(), background, 8)); + LineTheme.padding(tag, LineTheme.SM, 3, LineTheme.SM, 3); + return tag; + } + + private void addBadge(LinearLayout badges, String value, int color) { + TextView badge = tag(value, color, LineTheme.SURFACE_LIGHT); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + params.leftMargin = LineTheme.dp(getContext(), LineTheme.XS); + badges.addView(badge, params); + } + + private void loadStarred(SkillHubModels.Detail value) { + starBusy = true; + updateStarButton(); + new Thread(() -> { + try { + SkillHubSessionClient.Session session = sessionClient.currentSession(); + if (!session.isAuthenticated()) { + main.post(() -> { + starBusy = false; + starred = Boolean.FALSE; + updateStarButton(); + }); + return; + } + boolean current = sessionClient.starred( + value.getSlug(), namespaceHandle(value)); + main.post(() -> { + starBusy = false; + starred = current; + updateStarButton(); + }); + } catch (Exception e) { + main.post(() -> { + starBusy = false; + updateStarButton(); + }); + } + }, "skillhub-star-state").start(); + } + + private void toggleStar(SkillHubModels.Detail value) { + if (starBusy) { + return; + } + starBusy = true; + updateStarButton(); + new Thread(() -> { + try { + SkillHubSessionClient.Session session = sessionClient.currentSession(); + if (!session.isAuthenticated()) { + main.post(() -> { + starBusy = false; + updateStarButton(); + Toast.makeText(getContext(), + getString(R.string.skillhub_login_to_star), + Toast.LENGTH_SHORT).show(); + listener.onLogin(); + }); + return; + } + String namespace = namespaceHandle(value); + boolean current = starred != null + ? starred : sessionClient.starred(value.getSlug(), namespace); + boolean next = !current; + sessionClient.setStarred(value.getSlug(), namespace, next); + main.post(() -> { + starred = next; + starBusy = false; + updateStarButton(); + Toast.makeText(getContext(), + next ? getString(R.string.skillhub_star_success) + : getString(R.string.skillhub_unstar_success), + Toast.LENGTH_SHORT).show(); + }); + } catch (Exception e) { + main.post(() -> { + starBusy = false; + updateStarButton(); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-star").start(); + } + + private void updateStarButton() { + if (!(starButton instanceof LinearLayout)) { + return; + } + starButton.setEnabled(!starBusy); + starButton.setAlpha(starBusy ? 0.55f : 1f); + LinearLayout button = (LinearLayout) starButton; + if (button.getChildCount() > 1 && button.getChildAt(1) instanceof TextView) { + ((TextView) button.getChildAt(1)).setText( + Boolean.TRUE.equals(starred) ? getString(R.string.skillhub_unstar) + : getString(R.string.skillhub_star)); + } + starButton.setContentDescription( + Boolean.TRUE.equals(starred) ? getString(R.string.skillhub_unstar) + : getString(R.string.skillhub_star)); + } + + private void copyPrompt(SkillHubModels.Detail value) { + ShareHelper.copy(getContext(), installPrompt(value)); + Toast.makeText(getContext(), getString(R.string.skillhub_prompt_copied), Toast.LENGTH_SHORT).show(); + } + + private void share(SkillHubModels.Detail value) { + ShareHelper.shareText(getContext(), value.getName() + "\n" + value.getDescription() + + "\nhttps://skillhub.cn/skills/" + value.getCanonicalName().replaceFirst("^@", "")); + } + + private String installPrompt(SkillHubModels.Detail value) { + return getString(R.string.skillhub_install_prompt, + value.getCanonicalName(), value.getVersion()); + } + + private void openHttps(String rawUrl) { + try { + Uri uri = Uri.parse(rawUrl == null ? "" : rawUrl.trim()); + if (!"https".equalsIgnoreCase(uri.getScheme())) { + Toast.makeText(getContext(), getString(R.string.skillhub_https_only), + Toast.LENGTH_SHORT).show(); + return; + } + getContext().startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } catch (Exception e) { + Toast.makeText(getContext(), getString(R.string.toast_open_link_failed, + rawUrl == null ? "" : rawUrl), Toast.LENGTH_SHORT).show(); + } + } + + private void showInstallConfirm(SkillHubModels.Detail value, TextView installButton) { + final String[] selectedLocation = {SkillRecord.LOCATION_APP}; + LinearLayout panel = new LinearLayout(getContext()); + panel.setOrientation(VERTICAL); + panel.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 16, LineTheme.BORDER_LIGHT)); + LineTheme.padding(panel, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + + LinearLayout heading = new LinearLayout(getContext()); + heading.setOrientation(HORIZONTAL); + heading.setGravity(Gravity.CENTER_VERTICAL); + IconButtonView packageIcon = new IconButtonView(getContext(), IconButtonView.PACKAGE); + packageIcon.setIconColor(LineTheme.ACCENT); + packageIcon.setIconSizeDp(42, 22); + packageIcon.setClickable(false); + packageIcon.setFocusable(false); + packageIcon.setBackground(null); + heading.addView(packageIcon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 42), LineTheme.dp(getContext(), 42))); + LinearLayout titleCopy = new LinearLayout(getContext()); + titleCopy.setOrientation(VERTICAL); + LinearLayout.LayoutParams titleCopyParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + titleCopyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.MD); + heading.addView(titleCopy, titleCopyParams); + TextView title = LineTheme.textMedium(getContext(), + getString(R.string.skillhub_install_skill, value.getName()), + LineTheme.FONT_LG, LineTheme.TEXT); + title.setMaxLines(2); + titleCopy.addView(title); + TextView subtitle = LineTheme.text(getContext(), + getString(R.string.skillhub_install_scope_desc), + LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + subtitleParams.topMargin = LineTheme.dp(getContext(), 3); + titleCopy.addView(subtitle, subtitleParams); + panel.addView(heading); + + LinearLayout facts = new LinearLayout(getContext()); + facts.setOrientation(HORIZONTAL); + facts.addView(tag("SkillHub", LineTheme.TEXT_SECONDARY, LineTheme.SURFACE_LIGHT)); + addBadge(facts, "v" + value.getVersion(), LineTheme.TEXT_SECONDARY); + addBadge(facts, getString(R.string.skillhub_file_count, value.getFiles().size()), + LineTheme.TEXT_SECONDARY); + LinearLayout.LayoutParams factsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + factsParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + panel.addView(facts, factsParams); + + TextView locationLabel = LineTheme.textMedium(getContext(), + getString(R.string.skillhub_install_location), + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY); + LinearLayout.LayoutParams locationLabelParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + locationLabelParams.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + panel.addView(locationLabel, locationLabelParams); + + LinearLayout appOption = installLocationOption( + IconButtonView.SMARTPHONE, getString(R.string.skillhub_location_app_title), + getString(R.string.skillhub_location_app_desc), true); + LinearLayout projectOption = installLocationOption( + IconButtonView.FOLDER, getString(R.string.skillhub_location_project_title), + getString(R.string.skillhub_location_project_desc), false); + LinearLayout.LayoutParams optionParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + optionParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + panel.addView(appOption, optionParams); + LinearLayout.LayoutParams projectParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + projectParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + panel.addView(projectOption, projectParams); + appOption.setOnClickListener(v -> { + selectedLocation[0] = SkillRecord.LOCATION_APP; + styleInstallLocation(appOption, true); + styleInstallLocation(projectOption, false); + }); + projectOption.setOnClickListener(v -> { + selectedLocation[0] = SkillRecord.LOCATION_PROJECT; + styleInstallLocation(appOption, false); + styleInstallLocation(projectOption, true); + }); + + if (value.hasScripts() || value.requiresApiKey()) { + String warning = value.hasScripts() + ? getString(R.string.skillhub_contains_scripts_confirm) + : getString(R.string.skillhub_may_require_api_key_confirm); + if (value.hasScripts() && value.requiresApiKey()) { + warning += "\n" + getString(R.string.skillhub_may_also_require_api_key); + } + LinearLayout warningCard = new LinearLayout(getContext()); + warningCard.setOrientation(HORIZONTAL); + warningCard.setGravity(Gravity.TOP); + warningCard.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 9)); + LineTheme.padding(warningCard, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + IconButtonView warningIcon = new IconButtonView(getContext(), IconButtonView.CIRCLE_ALERT); + warningIcon.setIconColor(LineTheme.WARNING); + warningIcon.setIconSizeDp(24, 16); + warningIcon.setClickable(false); + warningIcon.setFocusable(false); + warningCard.addView(warningIcon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); + TextView warningText = LineTheme.text(getContext(), warning, LineTheme.FONT_XS, + LineTheme.WARNING, Typeface.NORMAL); + LinearLayout.LayoutParams warningTextParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + warningTextParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + warningCard.addView(warningText, warningTextParams); + LinearLayout.LayoutParams warningParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + warningParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + panel.addView(warningCard, warningParams); + } + + LinearLayout actions = new AdaptiveActionsView(getContext()); + actions.setOrientation(HORIZONTAL); + TextView cancel = dialogButton(getString(R.string.skillhub_cancel), false); + TextView install = dialogButton(getString(R.string.common_install), true); + actions.addView(cancel, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + LinearLayout.LayoutParams installParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + installParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + actions.addView(install, installParams); + LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + actionsParams.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + panel.addView(actions, actionsParams); + + Dialog dialog = DialogBuilder.create(getContext()); + dialog.setCanceledOnTouchOutside(true); + cancel.setOnClickListener(v -> dialog.dismiss()); + install.setOnClickListener(v -> { + String location = selectedLocation[0]; + install.setEnabled(false); + cancel.setEnabled(false); + appOption.setEnabled(false); + projectOption.setEnabled(false); + installButton.setEnabled(false); + install.setText(getString(R.string.skillhub_installing)); + installButton.setText(getString(R.string.skillhub_installing)); + new Thread(() -> { + try { + listener.onInstall(location, value.getSlug(), value.getVersion()); + main.post(() -> { + dialog.dismiss(); + installButton.setText(getString(R.string.skillhub_installed)); + Toast.makeText(getContext(), + getString(R.string.skillhub_install_success), + Toast.LENGTH_LONG).show(); + }); + } catch (Exception e) { + main.post(() -> { + install.setEnabled(true); + cancel.setEnabled(true); + appOption.setEnabled(true); + projectOption.setEnabled(true); + installButton.setEnabled(true); + install.setText(getString(R.string.common_install)); + installButton.setText(getString(R.string.skillhub_select_location_install)); + Toast.makeText(getContext(), safeMessage(e), + Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-install").start(); + }); + DialogBuilder.showInset(dialog, panel); + } + + private LinearLayout installLocationOption( + int iconType, String title, String description, boolean selected) { + LinearLayout option = new LinearLayout(getContext()); + option.setOrientation(HORIZONTAL); + option.setGravity(Gravity.CENTER_VERTICAL); + option.setClickable(true); + option.setFocusable(true); + option.setContentDescription(title); + LineTheme.padding(option, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + IconButtonView icon = new IconButtonView(getContext(), iconType); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(34, 19); + icon.setClickable(false); + icon.setFocusable(false); + option.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 34), LineTheme.dp(getContext(), 34))); + LinearLayout copy = new LinearLayout(getContext()); + copy.setOrientation(VERTICAL); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + copyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + option.addView(copy, copyParams); + copy.addView(LineTheme.textMedium(getContext(), title, LineTheme.FONT_SM, LineTheme.TEXT)); + TextView detail = LineTheme.text(getContext(), description, LineTheme.FONT_XS, + LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + detailParams.topMargin = LineTheme.dp(getContext(), 2); + copy.addView(detail, detailParams); + TextView indicator = LineTheme.textMedium(getContext(), selected ? "✓" : "", + LineTheme.FONT_LG, LineTheme.ACCENT); + indicator.setTag("selection-indicator"); + indicator.setGravity(Gravity.CENTER); + option.addView(indicator, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 30), LineTheme.dp(getContext(), 30))); + styleInstallLocation(option, selected); + return option; + } + + private void styleInstallLocation(LinearLayout option, boolean selected) { + option.setBackground(LineTheme.roundedStroke( + getContext(), selected ? LineTheme.ACCENT_MUTED : LineTheme.SURFACE_LIGHT, + 10, selected ? LineTheme.ACCENT : LineTheme.BORDER_LIGHT)); + View indicator = option.findViewWithTag("selection-indicator"); + if (indicator instanceof TextView) { + ((TextView) indicator).setText(selected ? "✓" : ""); + } + option.setSelected(selected); + } + + private TextView dialogButton(String value, boolean primary) { + TextView button = LineTheme.textMedium(getContext(), value, LineTheme.FONT_SM, + primary ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT); + button.setGravity(Gravity.CENTER); + button.setClickable(true); + button.setFocusable(true); + button.setBackground(primary + ? LineTheme.rounded(getContext(), LineTheme.ACCENT, 10) + : LineTheme.roundedStroke(getContext(), LineTheme.SURFACE_LIGHT, 10, LineTheme.BORDER_LIGHT)); + LineTheme.padding(button, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + return button; + } + + private void renderError(Exception error) { + body.removeAllViews(); + LinearLayout errorCard = section(getString(R.string.skillhub_detail_load_failed), + IconButtonView.CIRCLE_ALERT); + TextView message = LineTheme.text(getContext(), + safeMessage(error) + "\n" + getString(R.string.skillhub_retry_here), + LineTheme.FONT_SM, LineTheme.DANGER, Typeface.NORMAL); + message.setGravity(Gravity.CENTER); + errorCard.setClickable(true); + errorCard.setFocusable(true); + errorCard.setOnClickListener(v -> { + body.removeAllViews(); + body.addView(progress); + load(); + }); + addSectionContent(errorCard, message); + body.addView(errorCard, new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + private void configureReadingScale(MarkdownView markdown) { + SharedPreferences preferences = getContext().getSharedPreferences( + READING_PREFERENCES, Context.MODE_PRIVATE); + markdown.setTextScale(preferences.getFloat(MARKDOWN_TEXT_SCALE, 1f)); + markdown.setTextScaleListener(scale -> preferences.edit() + .putFloat(MARKDOWN_TEXT_SCALE, scale) + .apply()); + markdown.setPinchZoomEnabled(true); + } + + private TextView emptyText(String value) { + return LineTheme.text(getContext(), value, LineTheme.FONT_SM, + LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + } + + private String namespaceHandle(SkillHubModels.Detail value) { + String canonical = value.getCanonicalName(); + if (!canonical.startsWith("@")) { + return ""; + } + int slash = canonical.indexOf('/'); + return slash > 1 ? canonical.substring(1, slash) : ""; + } + + private String sourceName(String source) { + return "community".equalsIgnoreCase(source) + ? getString(R.string.skillhub_source_community) : source; + } + + private String join(List values) { + StringBuilder result = new StringBuilder(); + for (String value : values) { + if (result.length() > 0) { + result.append(" · "); + } + result.append(value); + } + return result.toString(); + } + + private String formatCount(long value) { + if (value >= 10000) { + return getString(R.string.skillhub_count_wan, value / 10000d); + } + return String.valueOf(value); + } + + private String formatBytes(long value) { + if (value >= 1024 * 1024) { + return String.format(Locale.getDefault(), "%.1f MB", value / (1024d * 1024d)); + } + if (value >= 1024) { + return String.format(Locale.getDefault(), "%.1f KB", value / 1024d); + } + return value + " B"; + } + + private String formatDate(long value) { + if (value <= 0) { + return getString(R.string.skillhub_dash); + } + return DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) + .format(new Date(value)); + } + + private String safeMessage(Exception error) { + return error.getMessage() == null + ? getString(R.string.skillhub_unknown_error) : error.getMessage(); + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java new file mode 100644 index 00000000..1d0642eb --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java @@ -0,0 +1,658 @@ +package cn.lineai.ui.component; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Typeface; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import cn.lineai.R; +import cn.lineai.data.service.ContextResourceProvider; +import cn.lineai.data.service.SkillHubClient; +import cn.lineai.data.service.SkillHubSessionClient; +import cn.lineai.model.SkillHubModels; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.util.ArrayList; +import java.util.List; + +public final class SkillStoreScreenView extends ScreenScaffoldView { + public interface Listener { + void onBack(); + void onOpen(String slug); + void onLogin(); + void onCenter(); + void onPublish(); + } + + private final Listener listener; + private final SkillHubClient client; + private final SkillHubSessionClient sessionClient; + private final SkillIconLoader iconLoader; + private final Handler main = new Handler(Looper.getMainLooper()); + private final LinearLayout results; + private final ProgressBar progress; + private final TextView status; + private final EditText search; + private LinearLayout accountRow; + private IconButtonView accountIcon; + private TextView accountTitle; + private TextView accountSubtitle; + private IconButtonView accountAction; + private TextView publishButton; + private SkillHubSessionClient.Session accountSession; + private final List filterChips = new ArrayList<>(); + private int requestGeneration; + private int page = 1; + private String sortBy = "downloads"; + + public SkillStoreScreenView(Context context, Listener listener) { + super(context, context.getString(R.string.skillhub_title_store), listener::onBack, null, true); + this.listener = listener; + this.client = new SkillHubClient(new ContextResourceProvider(context)); + this.sessionClient = new SkillHubSessionClient(new ContextResourceProvider(context)); + this.iconLoader = new SkillIconLoader(context); + LinearLayout content = getContent(); + LineTheme.padding(content, 16, 16, 16, 32); + + addIntro(content); + search = addSearch(content); + addFilters(content); + addLogin(content); + addPublish(content); + loadAccount(); + + progress = new ProgressBar(context); + LinearLayout.LayoutParams progressParams = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + progressParams.gravity = Gravity.CENTER_HORIZONTAL; + progressParams.topMargin = LineTheme.dp(context, LineTheme.LG); + content.addView(progress, progressParams); + + status = LineTheme.text(context, "", LineTheme.FONT_SM, + LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + status.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + statusParams.topMargin = LineTheme.dp(context, LineTheme.MD); + content.addView(status, statusParams); + + results = new LinearLayout(context); + results.setOrientation(VERTICAL); + LinearLayout.LayoutParams resultsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + resultsParams.topMargin = LineTheme.dp(context, LineTheme.MD); + content.addView(results, resultsParams); + + addPager(content); + updateFilterStyles(); + load(); + } + + private void addIntro(LinearLayout content) { + LinearLayout header = new LinearLayout(getContext()); + header.setOrientation(HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + + LinearLayout copy = new LinearLayout(getContext()); + copy.setOrientation(VERTICAL); + header.addView(copy, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + + copy.addView(LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_discover_community_skills), + LineTheme.FONT_XL, LineTheme.TEXT)); + TextView description = LineTheme.text(getContext(), + getContext().getString(R.string.skillhub_browse_install_desc), + LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams descriptionParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + descriptionParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + copy.addView(description, descriptionParams); + + IconButtonView storeIcon = new IconButtonView(getContext(), IconButtonView.SPARKLES); + storeIcon.setIconColor(LineTheme.ACCENT); + storeIcon.setIconSizeDp(44, 24); + storeIcon.setClickable(false); + storeIcon.setFocusable(false); + storeIcon.setBackground(null); + header.addView(storeIcon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 44), LineTheme.dp(getContext(), 44))); + content.addView(header); + } + + private EditText addSearch(LinearLayout content) { + LinearLayout searchBox = new LinearLayout(getContext()); + searchBox.setOrientation(HORIZONTAL); + searchBox.setGravity(Gravity.CENTER_VERTICAL); + searchBox.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.INPUT_BG, 12, LineTheme.BORDER_LIGHT)); + LineTheme.padding(searchBox, LineTheme.SM, 0, LineTheme.MD, 0); + + IconButtonView icon = new IconButtonView(getContext(), IconButtonView.SEARCH); + icon.setIconColor(LineTheme.TEXT_TERTIARY); + icon.setIconSizeDp(36, 18); + icon.setClickable(false); + icon.setFocusable(false); + searchBox.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 36), LineTheme.dp(getContext(), 44))); + + EditText field = new EditText(getContext()); + field.setSingleLine(true); + field.setHint(getContext().getString(R.string.skillhub_search_hint)); + field.setHintTextColor(LineTheme.TEXT_TERTIARY); + field.setTextColor(LineTheme.TEXT); + field.setTextSize(LineTheme.FONT_MD); + field.setInputType(InputType.TYPE_CLASS_TEXT); + field.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + field.setBackgroundColor(android.graphics.Color.TRANSPARENT); + field.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + page = 1; + load(); + return true; + } + return false; + }); + searchBox.addView(field, new LinearLayout.LayoutParams( + 0, LineTheme.dp(getContext(), 48), 1f)); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + content.addView(searchBox, params); + return field; + } + + private void addFilters(LinearLayout content) { + LinearLayout filters = new LinearLayout(getContext()); + filters.setOrientation(HORIZONTAL); + filters.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + content.addView(filters, params); + addFilter(filters, getContext().getString(R.string.skillhub_filter_hot_downloads), "downloads"); + addFilter(filters, getContext().getString(R.string.skillhub_filter_most_stars), "stars"); + addFilter(filters, getContext().getString(R.string.skillhub_filter_recent_update), "updated_at"); + } + + private void addFilter(LinearLayout host, String label, String value) { + TextView chip = LineTheme.textMedium(getContext(), label, + LineTheme.FONT_XS, LineTheme.TEXT_SECONDARY); + chip.setGravity(Gravity.CENTER); + chip.setClickable(true); + chip.setFocusable(true); + chip.setContentDescription(getContext().getString(R.string.skillhub_sort_by, label)); + LineTheme.padding(chip, LineTheme.SM, LineTheme.SM, LineTheme.SM, LineTheme.SM); + chip.setOnClickListener(v -> { + if (!value.equals(sortBy)) { + sortBy = value; + page = 1; + updateFilterStyles(); + load(); + } + }); + FilterChip filter = new FilterChip(value, chip); + filterChips.add(filter); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + if (!filterChips.isEmpty()) { + params.rightMargin = LineTheme.dp(getContext(), LineTheme.XS); + } + host.addView(chip, params); + } + + private void updateFilterStyles() { + for (FilterChip filter : filterChips) { + boolean selected = filter.value.equals(sortBy); + filter.view.setTextColor(selected ? LineTheme.ACCENT : LineTheme.TEXT_SECONDARY); + filter.view.setBackground(LineTheme.roundedStroke( + getContext(), + selected ? LineTheme.ACCENT_MUTED : LineTheme.SURFACE_ELEVATED, + 10, + selected ? LineTheme.ACCENT : LineTheme.BORDER)); + filter.view.setSelected(selected); + } + } + + private void addLogin(LinearLayout content) { + accountRow = new LinearLayout(getContext()); + accountRow.setOrientation(HORIZONTAL); + accountRow.setGravity(Gravity.CENTER_VERTICAL); + accountRow.setClickable(true); + accountRow.setFocusable(true); + accountRow.setContentDescription(getContext().getString(R.string.skillhub_account_label)); + accountRow.setOnClickListener(v -> { + if (accountSession != null && accountSession.isAuthenticated()) { + showAccountDialog(accountSession.getAccount()); + } else { + listener.onLogin(); + } + }); + accountRow.setBackground(LineTheme.rounded(getContext(), LineTheme.SURFACE_ELEVATED, 12)); + LineTheme.padding(accountRow, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + + accountIcon = new IconButtonView(getContext(), IconButtonView.USER); + accountIcon.setIconColor(LineTheme.ACCENT); + accountIcon.setIconSizeDp(36, 19); + accountIcon.setClickable(false); + accountIcon.setFocusable(false); + accountIcon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 9)); + accountRow.addView(accountIcon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 36), LineTheme.dp(getContext(), 36))); + + LinearLayout copy = new LinearLayout(getContext()); + copy.setOrientation(VERTICAL); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + copyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + accountRow.addView(copy, copyParams); + accountTitle = LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_checking_account), + LineTheme.FONT_SM, LineTheme.TEXT); + copy.addView(accountTitle); + accountSubtitle = LineTheme.text(getContext(), + getContext().getString(R.string.skillhub_login_via_official), + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + subtitleParams.topMargin = LineTheme.dp(getContext(), 2); + copy.addView(accountSubtitle, subtitleParams); + + accountAction = new IconButtonView(getContext(), IconButtonView.EXTERNAL_LINK); + accountAction.setIconColor(LineTheme.TEXT_TERTIARY); + accountAction.setIconSizeDp(28, 16); + accountAction.setClickable(false); + accountAction.setFocusable(false); + accountRow.addView(accountAction, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 28), LineTheme.dp(getContext(), 28))); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + content.addView(accountRow, params); + } + + private void addPublish(LinearLayout content) { + publishButton = LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_feature_center), + LineTheme.FONT_SM, LineTheme.ACCENT); + publishButton.setGravity(Gravity.CENTER); + publishButton.setClickable(true); + publishButton.setFocusable(true); + publishButton.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 12, LineTheme.ACCENT)); + LineTheme.padding(publishButton, LineTheme.MD, LineTheme.SM, + LineTheme.MD, LineTheme.SM); + publishButton.setOnClickListener(v -> listener.onCenter()); + publishButton.setVisibility(GONE); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + content.addView(publishButton, params); + } + + private void loadAccount() { + accountRow.setEnabled(false); + accountTitle.setText(getContext().getString(R.string.skillhub_checking_account)); + accountSubtitle.setText(getContext().getString(R.string.skillhub_login_via_official)); + new Thread(() -> { + try { + SkillHubSessionClient.Session session = sessionClient.currentSession(); + main.post(() -> renderAccount(session)); + } catch (Exception e) { + main.post(() -> { + accountSession = null; + publishButton.setVisibility(GONE); + accountRow.setEnabled(true); + accountTitle.setText(getContext().getString(R.string.skillhub_account_check_failed)); + accountSubtitle.setText(getContext().getString(R.string.skillhub_retry_here) + + " · " + safeMessage(e)); + accountAction.setIconType(IconButtonView.REFRESH_CW); + accountRow.setOnClickListener(v -> loadAccount()); + }); + } + }, "skillhub-session").start(); + } + + private void renderAccount(SkillHubSessionClient.Session session) { + accountSession = session; + accountRow.setEnabled(true); + accountRow.setOnClickListener(v -> { + if (accountSession != null && accountSession.isAuthenticated()) { + showAccountDialog(accountSession.getAccount()); + } else { + listener.onLogin(); + } + }); + if (!session.isAuthenticated()) { + publishButton.setVisibility(GONE); + accountTitle.setText(getContext().getString(R.string.skillhub_login_account)); + accountSubtitle.setText(getContext().getString(R.string.skillhub_login_desc)); + accountAction.setIconType(IconButtonView.EXTERNAL_LINK); + return; + } + publishButton.setVisibility(VISIBLE); + SkillHubSessionClient.Account account = session.getAccount(); + accountTitle.setText(account.getDisplayName()); + accountSubtitle.setText(account.getHandle().length() == 0 + ? getContext().getString(R.string.skillhub_logged_in) + : "@" + account.getHandle() + " · " + + getContext().getString(R.string.skillhub_logged_in_suffix)); + accountAction.setIconType(IconButtonView.CHEVRON_RIGHT); + } + + private void showAccountDialog(SkillHubSessionClient.Account account) { + LinearLayout panel = new LinearLayout(getContext()); + panel.setOrientation(VERTICAL); + panel.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 16, LineTheme.BORDER_LIGHT)); + LineTheme.padding(panel, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + + IconButtonView icon = new IconButtonView(getContext(), IconButtonView.USER); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(54, 27); + icon.setClickable(false); + icon.setFocusable(false); + icon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 14)); + LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 54), LineTheme.dp(getContext(), 54)); + iconParams.gravity = Gravity.CENTER_HORIZONTAL; + panel.addView(icon, iconParams); + + TextView name = LineTheme.textMedium(getContext(), account.getDisplayName(), + LineTheme.FONT_LG, LineTheme.TEXT); + name.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams nameParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + nameParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + panel.addView(name, nameParams); + if (account.getHandle().length() > 0) { + TextView handle = LineTheme.text(getContext(), "@" + account.getHandle(), + LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + handle.setGravity(Gravity.CENTER); + panel.addView(handle); + } + + TextView status = LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_account_connected), + LineTheme.FONT_SM, LineTheme.ACCENT); + status.setGravity(Gravity.CENTER); + status.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 9)); + LineTheme.padding(status, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + statusParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + panel.addView(status, statusParams); + + LinearLayout actions = new AdaptiveActionsView(getContext()); + actions.setOrientation(HORIZONTAL); + TextView close = accountDialogButton( + getContext().getString(R.string.skillhub_continue), false); + TextView logout = accountDialogButton( + getContext().getString(R.string.skillhub_logout), true); + actions.addView(close, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + LinearLayout.LayoutParams logoutParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + logoutParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + actions.addView(logout, logoutParams); + LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + actionsParams.topMargin = LineTheme.dp(getContext(), LineTheme.LG); + panel.addView(actions, actionsParams); + + Dialog dialog = DialogBuilder.create(getContext()); + close.setOnClickListener(v -> dialog.dismiss()); + logout.setOnClickListener(v -> { + close.setEnabled(false); + logout.setEnabled(false); + logout.setText(getContext().getString(R.string.skillhub_logging_out)); + new Thread(() -> { + try { + sessionClient.logout(); + main.post(() -> { + dialog.dismiss(); + renderAccount(SkillHubSessionClient.Session.signedOut()); + Toast.makeText(getContext(), getContext().getString(R.string.skillhub_logged_out_success), + Toast.LENGTH_SHORT).show(); + }); + } catch (Exception e) { + main.post(() -> { + close.setEnabled(true); + logout.setEnabled(true); + logout.setText(getContext().getString(R.string.skillhub_logout)); + Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); + }); + } + }, "skillhub-logout").start(); + }); + DialogBuilder.showInset(dialog, panel); + } + + private TextView accountDialogButton(String value, boolean danger) { + TextView button = LineTheme.textMedium(getContext(), value, LineTheme.FONT_SM, + danger ? LineTheme.DANGER : LineTheme.TEXT); + button.setGravity(Gravity.CENTER); + button.setClickable(true); + button.setFocusable(true); + button.setBackground(LineTheme.roundedStroke( + getContext(), danger ? LineTheme.DANGER_MUTED : LineTheme.SURFACE_LIGHT, + 10, danger ? LineTheme.DANGER : LineTheme.BORDER_LIGHT)); + LineTheme.padding(button, LineTheme.SM, LineTheme.MD, LineTheme.SM, LineTheme.MD); + return button; + } + + private void addPager(LinearLayout content) { + LinearLayout pager = new LinearLayout(getContext()); + pager.setGravity(Gravity.CENTER); + TextView previous = pagerButton(getContext().getString(R.string.skillhub_previous_page)); + previous.setOnClickListener(v -> { + if (page > 1) { + page--; + load(); + } + }); + pager.addView(previous); + TextView next = pagerButton(getContext().getString(R.string.skillhub_next_page)); + next.setOnClickListener(v -> { + page++; + load(); + }); + LinearLayout.LayoutParams nextParams = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + nextParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); + pager.addView(next, nextParams); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.topMargin = LineTheme.dp(getContext(), LineTheme.MD); + content.addView(pager, params); + } + + private TextView pagerButton(String label) { + TextView button = LineTheme.textMedium(getContext(), label, + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY); + button.setGravity(Gravity.CENTER); + button.setClickable(true); + button.setFocusable(true); + button.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 10, LineTheme.BORDER_LIGHT)); + LineTheme.padding(button, LineTheme.LG, LineTheme.SM, LineTheme.LG, LineTheme.SM); + return button; + } + + private void load() { + final int generation = ++requestGeneration; + final int requestedPage = page; + final String keyword = search.getText().toString(); + final String requestedSort = sortBy; + progress.setVisibility(VISIBLE); + status.setText(getContext().getString(R.string.skillhub_loading_page, requestedPage)); + results.removeAllViews(); + new Thread(() -> { + try { + SkillHubModels.Page value = client.list( + requestedPage, 20, keyword, "", "all", requestedSort, "desc"); + main.post(() -> { + if (generation != requestGeneration) return; + progress.setVisibility(GONE); + render(value, requestedPage); + }); + } catch (Exception e) { + main.post(() -> { + if (generation != requestGeneration) return; + progress.setVisibility(GONE); + status.setText(getContext().getString(R.string.skillhub_load_failed_retry) + "\n" + safeMessage(e)); + status.setOnClickListener(v -> load()); + }); + } + }, "skillhub-list").start(); + } + + private void render(SkillHubModels.Page value, int requestedPage) { + status.setOnClickListener(null); + status.setText(value.getSkills().isEmpty() + ? getContext().getString(R.string.skillhub_no_matching_skills) + : getContext().getString(R.string.skillhub_page_count, + requestedPage, formatCount(value.getTotal()))); + for (SkillHubModels.Summary skill : value.getSkills()) { + results.addView(card(skill)); + } + } + + private View card(SkillHubModels.Summary skill) { + LinearLayout card = new LinearLayout(getContext()); + card.setOrientation(HORIZONTAL); + card.setGravity(Gravity.CENTER_VERTICAL); + card.setClickable(true); + card.setFocusable(true); + card.setContentDescription(skill.getName() + getContext().getString(R.string.skillhub_view_details)); + card.setOnClickListener(v -> listener.onOpen(skill.getSlug())); + card.setBackground(LineTheme.pressable(getContext())); + LineTheme.padding(card, 0, 20, 0, 20); + LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + cardParams.bottomMargin = LineTheme.dp(getContext(), LineTheme.SM); + card.setLayoutParams(cardParams); + + IconButtonView icon = new IconButtonView(getContext(), IconButtonView.PACKAGE); + icon.setIconColor(LineTheme.ACCENT); + icon.setIconSizeDp(48, 25); + icon.setClickable(false); + icon.setFocusable(false); + + card.addView(icon, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 48), LineTheme.dp(getContext(), 48))); + iconLoader.load(skill.getIconUrl(), icon); + + LinearLayout copy = new LinearLayout(getContext()); + copy.setOrientation(VERTICAL); + LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f); + copyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.MD); + copyParams.rightMargin = LineTheme.dp(getContext(), LineTheme.XS); + card.addView(copy, copyParams); + + LinearLayout titleRow = new LinearLayout(getContext()); + titleRow.setOrientation(HORIZONTAL); + titleRow.setGravity(Gravity.CENTER_VERTICAL); + TextView title = LineTheme.textMedium(getContext(), skill.getName(), + LineTheme.FONT_MD, LineTheme.TEXT); + title.setMaxLines(2); + titleRow.addView(title, new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f)); + if (skill.isVerified()) { + titleRow.addView(tag(getContext().getString(R.string.skillhub_verified), LineTheme.ACCENT, LineTheme.ACCENT_MUTED)); + } + copy.addView(titleRow); + + String owner = skill.getOwner().length() == 0 ? "SkillHub" : skill.getOwner(); + TextView ownerView = LineTheme.text(getContext(), owner, + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams ownerParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + ownerParams.topMargin = LineTheme.dp(getContext(), 2); + copy.addView(ownerView, ownerParams); + + if (skill.getCategory().length() > 0 || skill.requiresApiKey()) { + LinearLayout tags = new LinearLayout(getContext()); + tags.setOrientation(HORIZONTAL); + LinearLayout.LayoutParams tagsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + tagsParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + if (skill.getCategory().length() > 0) { + tags.addView(tag(skill.getCategory(), LineTheme.TEXT_SECONDARY, LineTheme.SURFACE_LIGHT)); + } + if (skill.requiresApiKey()) { + TextView apiKey = tag(getContext().getString(R.string.skillhub_requires_api_key), LineTheme.WARNING, LineTheme.SURFACE_LIGHT); + LinearLayout.LayoutParams apiParams = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + apiParams.leftMargin = LineTheme.dp(getContext(), LineTheme.XS); + tags.addView(apiKey, apiParams); + } + + } + + TextView description = LineTheme.text(getContext(), skill.getDescription(), + LineTheme.FONT_SM, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + description.setMaxLines(2); + LinearLayout.LayoutParams descriptionParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + descriptionParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + copy.addView(description, descriptionParams); + + String version = skill.getVersion().length() == 0 ? "" : " · v" + skill.getVersion(); + TextView stats = LineTheme.text(getContext(), + "↓ " + formatCount(skill.getDownloads()) + " ☆ " + formatCount(skill.getStars()) + version, + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LinearLayout.LayoutParams statsParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + statsParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); + + + IconButtonView chevron = new IconButtonView(getContext(), IconButtonView.CHEVRON_RIGHT); + chevron.setIconColor(LineTheme.TEXT_TERTIARY); + chevron.setIconSizeDp(24, 16); + chevron.setClickable(false); + chevron.setFocusable(false); + card.addView(chevron, new LinearLayout.LayoutParams( + LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); + return card; + } + + private TextView tag(String value, int color, int background) { + TextView tag = LineTheme.textMedium(getContext(), value, LineTheme.FONT_XS, color); + tag.setSingleLine(true); + tag.setBackground(LineTheme.rounded(getContext(), background, 8)); + LineTheme.padding(tag, LineTheme.SM, 3, LineTheme.SM, 3); + return tag; + } + + private String formatCount(long value) { + if (value >= 10000) { + return getContext().getString(R.string.skillhub_count_wan, value / 10000d); + } + return String.valueOf(value); + } + + private String safeMessage(Exception e) { + return e.getMessage() == null ? getContext().getString(R.string.skillhub_unknown_error) : e.getMessage(); + } + + private static final class FilterChip { + private final String value; + private final TextView view; + + private FilterChip(String value, TextView view) { + this.value = value; + this.view = view; + } + } +} diff --git a/app/src/main/java/cn/lineai/ui/component/SlashCommandPopup.java b/app/src/main/java/cn/lineai/ui/component/SlashCommandPopup.java index ad287a94..e5a1f79e 100644 --- a/app/src/main/java/cn/lineai/ui/component/SlashCommandPopup.java +++ b/app/src/main/java/cn/lineai/ui/component/SlashCommandPopup.java @@ -47,6 +47,7 @@ public Row(String label, String description, Runnable onClick) { private final Context context; private final PopupWindow popup; private final LinearLayout content; + private final android.widget.ScrollView viewport; private int selectedIndex = -1; private int lastRowCount = 0; private String lastTitle = null; @@ -56,7 +57,10 @@ public SlashCommandPopup(Context context) { content = new LinearLayout(context); content.setOrientation(LinearLayout.VERTICAL); content.setBackground(LineTheme.roundedStroke(context, LineTheme.INPUT_BG, 14, LineTheme.BORDER_LIGHT)); - LineTheme.padding(content, 3, 3, 3, 3); + LineTheme.padding(content, 8, 8, 8, 8); + viewport = new android.widget.ScrollView(context); + viewport.setFillViewport(false);viewport.addView(content); + viewport.setBackground(LineTheme.rounded(context,LineTheme.INPUT_BG,14));viewport.setClipToOutline(true); popup = new PopupWindow(context); popup.setOutsideTouchable(true); popup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -73,11 +77,6 @@ public void show(String title, List rows) { return; } String safeTitle = title == null ? "" : title; - if (popup.isShowing() - && safeTitle.equals(lastTitle) - && rows.size() == lastRowCount) { - return; - } content.removeAllViews(); lastTitle = safeTitle; lastRowCount = rows.size(); @@ -105,7 +104,8 @@ public void showAtAnchor(View anchor) { int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); content.measure(widthMeasureSpec, heightMeasureSpec); int popupWidth = anchor.getWidth() - 2 * LineTheme.dp(context, LineTheme.LG); - int popupHeight = content.getMeasuredHeight(); + int[] anchorLocation = new int[2]; anchor.getLocationOnScreen(anchorLocation); + int popupHeight = Math.min(content.getMeasuredHeight(),Math.max(LineTheme.dp(context,52),anchorLocation[1]-LineTheme.dp(context,24))); if (popupWidth <= 0 || popupHeight <= 0) { return; } @@ -119,7 +119,7 @@ public void showAtAnchor(View anchor) { anchor.getLocationOnScreen(location); int x = location[0] + LineTheme.dp(context, LineTheme.LG); int y = Math.max(0, location[1] - popupHeight - LineTheme.dp(context, 8)); - popup.setContentView(content); + popup.setContentView(viewport); popup.showAtLocation(anchor, Gravity.NO_GRAVITY, x, y); } @@ -160,7 +160,8 @@ private LinearLayout.LayoutParams titleParams() { private View rowView(Row row, int index) { LinearLayout container = new LinearLayout(context); container.setOrientation(LinearLayout.VERTICAL); - LineTheme.padding(container, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + LineTheme.padding(container, 16, 14, 16, 14); + container.setMinimumHeight(LineTheme.dp(context, 52)); container.setGravity(Gravity.CENTER_VERTICAL); LinearLayout row1 = new LinearLayout(context); diff --git a/app/src/main/java/cn/lineai/ui/component/SshSettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/SshSettingsScreenView.java index 2a7b16cd..1b2b719b 100644 --- a/app/src/main/java/cn/lineai/ui/component/SshSettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SshSettingsScreenView.java @@ -39,7 +39,7 @@ public SshSettingsScreenView(Context context, Listener listener) { SshConfig config = listener.onLoadConfig(); LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 28, 8, 28, 48); LinearLayout intro = card(context); intro.addView(title(context, context.getString(R.string.screen_ssh_section_server))); @@ -66,10 +66,12 @@ public SshSettingsScreenView(Context context, Listener listener) { form.addView(portField, formParams(context)); form.addView(usernameField, formParams(context)); form.addView(passwordField, formParams(context)); - form.addView(privateKeyField, formParams(context)); - form.addView(passphraseField, formParams(context)); + DisclosureSectionView keys = new DisclosureSectionView(context, context.getString(R.string.screen_ssh_field_private_key), !config.getPrivateKey().isEmpty()); + keys.getBody().addView(privateKeyField, formParams(context)); + keys.getBody().addView(passphraseField, formParams(context)); + form.addView(keys, formParams(context)); - LinearLayout actions = new LinearLayout(context); + LinearLayout actions = new AdaptiveActionsView(context); actions.setOrientation(HORIZONTAL); LinearLayout saveButton = button(context, context.getString(R.string.screen_ssh_save), IconButtonView.SAVE, false, v -> { listener.onSaveConfig(readConfig()); @@ -163,14 +165,14 @@ private void setStatus(String title, String message, boolean error) { private String describeException(Exception error) { if (error == null) { - return "未知错误"; + return getString(R.string.common_unknown_error); } String message = error.getMessage(); if (message != null && message.trim().length() > 0) { return message.trim(); } String name = error.getClass().getSimpleName(); - return name.length() == 0 ? "未知错误" : name; + return name.length() == 0 ? getString(R.string.common_unknown_error) : name; } private LinearLayout button(Context context, String label, int iconType, boolean primary, View.OnClickListener listener) { @@ -195,8 +197,8 @@ private LinearLayout button(Context context, String label, int iconType, boolean private LinearLayout card(Context context) { LinearLayout card = new LinearLayout(context); card.setOrientation(VERTICAL); - card.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LineTheme.padding(card, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + card.setBackground(null); + LineTheme.padding(card, 0, 16, 0, 24); return card; } @@ -212,7 +214,7 @@ private TextView desc(Context context, String text) { private LinearLayout.LayoutParams formParams(Context context) { LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - params.topMargin = LineTheme.dp(context, LineTheme.MD); + params.topMargin = LineTheme.dp(context, 24); return params; } diff --git a/app/src/main/java/cn/lineai/ui/component/StorageManagementScreenView.java b/app/src/main/java/cn/lineai/ui/component/StorageManagementScreenView.java index be97bf0a..170c228a 100644 --- a/app/src/main/java/cn/lineai/ui/component/StorageManagementScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/StorageManagementScreenView.java @@ -44,12 +44,13 @@ public StorageManagementScreenView(Context context, Listener listener) { this.refreshButton = (RefreshCwButtonView) getRightAction(); this.refreshButton.setOnClickListener(v -> loadStats()); LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 16, 8, 16, 32); LinearLayout summary = new LinearLayout(context); summary.setOrientation(VERTICAL); - summary.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LineTheme.padding(summary, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + + summary.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 14, LineTheme.BORDER_LIGHT)); + LineTheme.padding(summary, 16, 16, 16, 20); TextView label = LineTheme.textMedium(context, context.getString(R.string.screen_storage_counted), LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY); summary.addView(label, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); totalSizeView = LineTheme.text(context, context.getString(R.string.screen_storage_calculating), LineTheme.FONT_XXL, LineTheme.TEXT, Typeface.BOLD); @@ -88,18 +89,21 @@ public StorageManagementScreenView(Context context, Listener listener) { } private static View createRefreshButton(Context context) { - return new RefreshCwButtonView(context, 18); + View button = new RefreshCwButtonView(context, ScreenHeaderView.ICON_SIZE_DP); + button.setContentDescription(context.getString(R.string.common_refresh)); + return button; } private LinearLayout createStorageRow(int iconType, String title, String desc) { LinearLayout row = new LinearLayout(context); row.setOrientation(HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); - row.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LineTheme.padding(row, LineTheme.MD, LineTheme.MD, LineTheme.MD, LineTheme.MD); + + row.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE_ELEVATED, 14, LineTheme.BORDER_LIGHT)); + LineTheme.padding(row, 12, 16, 12, 16); FrameLayout iconWrap = new FrameLayout(context); - iconWrap.setBackground(LineTheme.rounded(context, LineTheme.ACCENT_MUTED, 19)); + iconWrap.setBackground(null); IconButtonView icon = new IconButtonView(context, iconType); icon.setIconColor(LineTheme.ACCENT); icon.setIconSizeDp(38, 19); @@ -161,4 +165,4 @@ private void updateViews(StorageStatsUiModel stats) { public void refresh() { loadStats(); } -} \ No newline at end of file +} diff --git a/app/src/main/java/cn/lineai/ui/component/SwitchRowView.java b/app/src/main/java/cn/lineai/ui/component/SwitchRowView.java index b42b4e79..f596085a 100644 --- a/app/src/main/java/cn/lineai/ui/component/SwitchRowView.java +++ b/app/src/main/java/cn/lineai/ui/component/SwitchRowView.java @@ -40,14 +40,17 @@ public SwitchRowView(Context context, int iconType, String label, String desc, b labels.addView(title, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); if (desc != null && desc.length() > 0) { - TextView description = LineTheme.text(context, desc, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + TextView description = LineTheme.text(context, desc, 14, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(context, 2); + descParams.topMargin = LineTheme.dp(context, 6); labels.addView(description, descParams); } Switch toggle = new Switch(context); toggle.setChecked(value); + toggle.setContentDescription(label); + toggle.setMinimumHeight(LineTheme.dp(context, 48)); + setBackground(LineTheme.pressable(context)); tintSwitch(toggle, listener); addView(toggle, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); setOnClickListener(v -> toggle.setChecked(!toggle.isChecked())); diff --git a/app/src/main/java/cn/lineai/ui/component/TermuxIntegrationScreenView.java b/app/src/main/java/cn/lineai/ui/component/TermuxIntegrationScreenView.java index c74c8343..744cb074 100644 --- a/app/src/main/java/cn/lineai/ui/component/TermuxIntegrationScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/TermuxIntegrationScreenView.java @@ -40,7 +40,7 @@ public TermuxIntegrationScreenView(Context context, Listener listener) { this.listener = listener; LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 28, 8, 28, 48); LinearLayout intro = card(context); intro.addView(title(context, context.getString(R.string.screen_termux_section_use))); @@ -221,8 +221,8 @@ private LinearLayout button(Context context, String label, int iconType, boolean private LinearLayout card(Context context) { LinearLayout card = new LinearLayout(context); card.setOrientation(VERTICAL); - card.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LineTheme.padding(card, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + card.setBackground(null); + LineTheme.padding(card, 0, 16, 0, 24); return card; } @@ -243,7 +243,7 @@ private void addCard(LinearLayout content, LinearLayout card) { content.addView(card, params); } - private static final class GridLikeActions extends LinearLayout { + private static final class GridLikeActions extends ScreenSurfaceView { GridLikeActions(Context context) { super(context); setOrientation(VERTICAL); diff --git a/app/src/main/java/cn/lineai/ui/component/TextSelectionDialog.java b/app/src/main/java/cn/lineai/ui/component/TextSelectionDialog.java index 9297be6c..780b992f 100644 --- a/app/src/main/java/cn/lineai/ui/component/TextSelectionDialog.java +++ b/app/src/main/java/cn/lineai/ui/component/TextSelectionDialog.java @@ -15,9 +15,9 @@ public static void show(Context context, String content) { EditText editText = new EditText(context); editText.setText(content); editText.setTextSize(15); - editText.setTextColor(Color.WHITE); - editText.setBackgroundColor(0xFF1E1E2E); - editText.setPadding(30, 30, 30, 30); + editText.setTextColor(cn.lineai.ui.theme.LineTheme.TEXT); + editText.setBackgroundColor(cn.lineai.ui.theme.LineTheme.BG); + cn.lineai.ui.theme.LineTheme.padding(editText, 24, 24, 24, 24); editText.setFocusable(true); editText.setFocusableInTouchMode(true); editText.setKeyListener(null); // Read-only @@ -27,7 +27,7 @@ public static void show(Context context, String content) { ScrollView scrollView = new ScrollView(context); scrollView.addView(editText); - new AlertDialog.Builder(context) + new LineAlertDialog.Builder(context) .setTitle(R.string.dialog_select_text_title) .setView(scrollView) .setPositiveButton(R.string.common_close, null) diff --git a/app/src/main/java/cn/lineai/ui/component/ThemeSettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/ThemeSettingsScreenView.java index 825efb5f..effb09d3 100644 --- a/app/src/main/java/cn/lineai/ui/component/ThemeSettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ThemeSettingsScreenView.java @@ -176,7 +176,9 @@ private void addCustomHeader(LinearLayout content) { LinearLayout header = new LinearLayout(context); header.setOrientation(HORIZONTAL); header.setGravity(Gravity.CENTER_VERTICAL); + LineTheme.padding(header, 16, 0, 16, 0); SectionHeaderView title = new SectionHeaderView(context, getResources().getString(R.string.screen_theme_custom_colors)); + LineTheme.padding(title, 0, 0, 8, 0); header.addView(title, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); IconButtonView reset = new IconButtonView(context, IconButtonView.ROTATE_CCW); diff --git a/app/src/main/java/cn/lineai/ui/component/ToolApprovalView.java b/app/src/main/java/cn/lineai/ui/component/ToolApprovalView.java new file mode 100644 index 00000000..0b2dd7e4 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/ToolApprovalView.java @@ -0,0 +1,151 @@ +package cn.lineai.ui.component; + +import android.content.Context; +import android.graphics.Typeface; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import cn.lineai.R; +import cn.lineai.model.ToolApproval; +import cn.lineai.tool.ToolNames; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.tool.ui.ToolCallUtils; +import cn.lineai.ui.theme.BoundedScrollView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import org.json.JSONObject; + +/** Bottom execution request; occupies the composer's slot without altering its draft. */ +public final class ToolApprovalView extends LinearLayout { + private final LinearLayout buttons; + private final TextView title; + private final TextView reason; + private final TextView command; + private final TextView always; + private final TextView once; + private final TextView reject; + private final IconButtonView icon; + private ToolApproval approval; + private ToolReviewListener listener; + private String submittedId = ""; + + public ToolApprovalView(Context context) { + super(context); setOrientation(VERTICAL); + LineTheme.padding(this, 16, 10, 16, 16); + LinearLayout panel = new LinearLayout(context); panel.setOrientation(VERTICAL); + LineTheme.padding(panel, 16, 12, 16, 12); + panel.setBackground(LineTheme.roundedStroke(context, LineTheme.BG, 20, LineTheme.BORDER)); + panel.setElevation(LineTheme.dp(context, 2)); + addView(panel, new LayoutParams(-1, -2)); + LinearLayout heading = new LinearLayout(context); heading.setGravity(Gravity.CENTER_VERTICAL); + icon = new IconButtonView(context, IconButtonView.TERMINAL); icon.setIconSizeDp(24, 16); + icon.setIconColor(LineTheme.TEXT_SECONDARY); icon.setClickable(false); icon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + heading.addView(icon, new LayoutParams(dp(24), dp(28))); + title = LineTheme.text(context, "", 12, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LayoutParams titleParams = new LayoutParams(-2, -2); titleParams.leftMargin = dp(4); heading.addView(title, titleParams); + panel.addView(heading, new LayoutParams(-1, -2)); + LinearLayout details = new LinearLayout(context); details.setOrientation(VERTICAL); + LineTheme.padding(details, 0, 0, 4, 0); + reason = LineTheme.text(context, "", 15, LineTheme.TEXT, Typeface.NORMAL); reason.setLineSpacing(dp(5), 1); + reason.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE); + LayoutParams reasonParams = new LayoutParams(-1, -2); reasonParams.topMargin = dp(6); details.addView(reason, reasonParams); + command = LineTheme.text(context, "", 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); command.setTypeface(Typeface.MONOSPACE); + command.setTextIsSelectable(true); command.setLineSpacing(dp(5), 1); command.setHorizontallyScrolling(true); + LayoutParams commandParams = new LayoutParams(-1, -2); commandParams.topMargin = dp(16); commandParams.bottomMargin = dp(10); + android.widget.HorizontalScrollView commandScroll = new android.widget.HorizontalScrollView(context); + commandScroll.setFillViewport(true); + commandScroll.addView(command, new android.widget.HorizontalScrollView.LayoutParams(-2, -2)); + details.addView(commandScroll, commandParams); + BoundedScrollView scroll = new BoundedScrollView(context, 156); + scroll.addView(details, new android.widget.ScrollView.LayoutParams(-1, -2)); panel.addView(scroll, new LayoutParams(-1, -2)); + buttons = new LinearLayout(context); buttons.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + buttons.setBaselineAligned(false); + reject = button(context.getString(R.string.chat_approval_deny), false); + once = button(context.getString(R.string.chat_approval_allow_once), true); + always = button(context.getString(R.string.chat_approval_allow_always), false); + always.setTooltipText(context.getString(R.string.chat_approval_scope)); + always.setContentDescription(context.getString(R.string.chat_approval_allow_always) + ". " + context.getString(R.string.chat_approval_scope)); + reject.setOnClickListener(v -> submit("rejected")); once.setOnClickListener(v -> submit("accepted")); always.setOnClickListener(v -> submit("permanent")); + buttons.addView(reject, new LayoutParams(0, -2, 1)); + LayoutParams onceParams = new LayoutParams(0, -2, 1); onceParams.leftMargin = dp(6); buttons.addView(once, onceParams); + LayoutParams alwaysParams = new LayoutParams(0, -2, 1); alwaysParams.leftMargin = dp(6); buttons.addView(always, alwaysParams); + LayoutParams buttonParams = new LayoutParams(-1, -2); buttonParams.topMargin = dp(10); panel.addView(buttons, buttonParams); + setVisibility(GONE); + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int available = View.MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight() - dp(32); + float needed = 0; + int count = 0; + for (int i = 0; i < buttons.getChildCount(); i++) { + TextView button = (TextView) buttons.getChildAt(i); + if (button.getVisibility() == GONE) continue; + needed += button.getPaint().measureText(button.getText().toString()) + button.getPaddingLeft() + button.getPaddingRight(); + count++; + } + boolean stacked = needed + dp(Math.max(0, count - 1) * 6) > available; + buttons.setOrientation(stacked ? VERTICAL : HORIZONTAL); + for (int i = 0; i < buttons.getChildCount(); i++) { + View button = buttons.getChildAt(i); + LayoutParams params = (LayoutParams) button.getLayoutParams(); + int width = stacked ? LayoutParams.MATCH_PARENT : 0; + float weight = stacked ? 0 : 1; + int left = !stacked && i > 0 ? dp(6) : 0; + int top = stacked && i > 0 ? dp(6) : 0; + if (params.width != width || params.weight != weight || params.leftMargin != left || params.topMargin != top) { + params.width = width; params.weight = weight; params.leftMargin = left; params.topMargin = top; + button.setLayoutParams(params); + } + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + public void setToolReviewListener(ToolReviewListener listener) { this.listener = listener; } + public void bind(ToolApproval next) { + boolean changed = approval == null || next == null || !approval.getReviewId().equals(next.getReviewId()); + approval = next; + if (changed) submittedId = ""; + setVisibility(next == null ? GONE : VISIBLE); + if (next == null) return; + boolean shell = ToolNames.SHELL_EXECUTE.equals(next.getCall().getName()); + boolean deleting = ToolNames.FILE_DELETE.equals(next.getCall().getName()); + title.setText(shell ? getContext().getString(R.string.chat_approval_terminal) + : deleting ? getContext().getString(cn.lineai.tool.ui.R.string.common_delete) : next.getCall().getName()); + icon.setIconType(shell ? IconButtonView.TERMINAL : IconButtonView.WRENCH); + JSONObject input = ToolCallUtils.parseInput(next.getCall()); + String explanation = input.optString("reason", input.optString("description", "")).trim(); + reason.setText(explanation.isEmpty() ? getContext().getString(R.string.chat_approval_reason) : explanation); + String action = shell ? input.optString("command") : input.optString("file_path", input.optString("path", next.getCall().getArguments())); + if (deleting && input.optJSONArray("paths") != null) { + StringBuilder paths = new StringBuilder(); + org.json.JSONArray list = input.optJSONArray("paths"); + for (int i = 0; i < list.length(); i++) { + if (paths.length() > 0) paths.append('\n'); + paths.append(list.optString(i)); + } + for (String field : new String[]{"file_path", "path"}) { + if (!input.optString(field).isEmpty()) paths.append('\n').append(input.optString(field)); + } + action = paths.toString(); + } + String cwd = input.optString("cwd", "").trim(); + command.setText(cwd.isEmpty() ? action : cwd + "\n" + action); + always.setVisibility(next.canAllowPermanently() ? VISIBLE : GONE); + boolean enabled = !submittedId.equals(next.getReviewId()); + reject.setEnabled(enabled); once.setEnabled(enabled); always.setEnabled(enabled); + } + private void submit(String state) { + if (approval == null || listener == null || submittedId.equals(approval.getReviewId())) return; + submittedId = approval.getReviewId(); reject.setEnabled(false); once.setEnabled(false); always.setEnabled(false); + listener.onToolReview(approval.getReviewId(), state, ""); + } + private TextView button(String label, boolean primary) { + TextView view = LineTheme.text(getContext(), label, 13, primary ? LineTheme.TEXT_ON_COLOR : LineTheme.TEXT, Typeface.NORMAL); + view.setGravity(Gravity.CENTER); view.setMinHeight(dp(44)); view.setFocusable(true); + LineTheme.padding(view, 8, 8, 8, 8); + view.setBackground(primary ? LineTheme.rounded(getContext(), LineTheme.ACCENT, 18) + : LineTheme.roundedStroke(getContext(), LineTheme.BG, 18, LineTheme.BORDER)); + return view; + } + private int dp(int value) { return LineTheme.dp(getContext(), value); } +} diff --git a/app/src/main/java/cn/lineai/ui/component/ToolSettingsScreenView.java b/app/src/main/java/cn/lineai/ui/component/ToolSettingsScreenView.java index 6b738fd2..18c52427 100644 --- a/app/src/main/java/cn/lineai/ui/component/ToolSettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ToolSettingsScreenView.java @@ -46,7 +46,7 @@ public ToolSettingsScreenView( this.imageUnderstandingModelLabel = imageUnderstandingModelLabel == null ? "" : imageUnderstandingModelLabel.trim(); this.imageGenerationModelLabel = imageGenerationModelLabel == null ? "" : imageGenerationModelLabel.trim(); LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 28, 8, 28, 48); addSectionHeader(content, context.getString(R.string.screen_tools_section_images)); addImageUnderstanding(content); @@ -316,8 +316,8 @@ private WebSearchConfig readWebSearchConfig( private LinearLayout card(Context context) { LinearLayout card = new LinearLayout(context); card.setOrientation(LinearLayout.VERTICAL); - card.setBackground(LineTheme.rounded(context, LineTheme.SURFACE_ELEVATED, 12)); - LineTheme.padding(card, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + card.setBackground(null); + LineTheme.padding(card, 0, 16, 0, 24); return card; } diff --git a/app/src/main/java/cn/lineai/ui/component/UserMessageView.java b/app/src/main/java/cn/lineai/ui/component/UserMessageView.java index dfba240d..1cc1496e 100644 --- a/app/src/main/java/cn/lineai/ui/component/UserMessageView.java +++ b/app/src/main/java/cn/lineai/ui/component/UserMessageView.java @@ -30,16 +30,16 @@ public UserMessageView(Context context) { super(context); setOrientation(VERTICAL); setGravity(Gravity.END); - LineTheme.padding(this, LineTheme.LG, 0, LineTheme.LG, LineTheme.MD); + LineTheme.padding(this, 28, 16, 28, 32); defaultPaddingLeft = getPaddingLeft(); defaultPaddingTop = getPaddingTop(); defaultPaddingRight = getPaddingRight(); defaultPaddingBottom = getPaddingBottom(); - contentText = LineTheme.text(context, "", 16, LineTheme.TEXT_ON_COLOR, Typeface.NORMAL); - contentText.setLineSpacing(LineTheme.dp(context, 2), 1.0f); + contentText = LineTheme.text(context, "", 16, LineTheme.textOn(LineTheme.USER_BUBBLE), Typeface.NORMAL); + contentText.setLineSpacing(LineTheme.dp(context, 5), 1.0f); contentText.setBackground(LineTheme.userBubble(context)); - LineTheme.padding(contentText, LineTheme.MD, 5, LineTheme.MD, 5); + LineTheme.padding(contentText, 15, 10, 15, 10); int horizontalPaddingPx = LineTheme.dp(context, LineTheme.LG) * 2; int availableWidth = context.getResources().getDisplayMetrics().widthPixels - horizontalPaddingPx; contentText.setMaxWidth((int) (availableWidth * 0.80f)); @@ -98,9 +98,18 @@ public void onRecall() { } } }); - LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 22)); + LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 44)); actionParams.topMargin = LineTheme.dp(context, 3); addView(actionBar, actionParams); + actionBar.setVisibility(GONE); + contentText.setOnLongClickListener(v -> { actionBar.setVisibility(actionBar.getVisibility() == VISIBLE ? GONE : VISIBLE); return true; }); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int available = Math.max(0, MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight()); + contentText.setMaxWidth((int) (available * 0.90f)); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); } public void setMessageActionListener(MessageActionListener listener) { @@ -116,8 +125,7 @@ public void bind(ChatMessage message) { String messageId = message.getId() == null ? "" : message.getId(); if (!lastAnimatedMessageId.equals(messageId)) { lastAnimatedMessageId = messageId; - setAlpha(0f); - animate().alpha(1f).setDuration(ENTRANCE_FADE_MS).start(); + actionBar.setVisibility(GONE); } String content = visibleUserContent(message); if (!lastContent.equals(content)) { diff --git a/app/src/main/java/cn/lineai/ui/model/ConversationTimeline.java b/app/src/main/java/cn/lineai/ui/model/ConversationTimeline.java new file mode 100644 index 00000000..fef8eeeb --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/model/ConversationTimeline.java @@ -0,0 +1,144 @@ +package cn.lineai.ui.model; + +import cn.lineai.model.ChatMessage; +import cn.lineai.model.tool.ToolCall; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.tool.ToolDisplayCategory; +import cn.lineai.tool.ui.ToolCallUtils; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Presentation only: never changes the messages sent to the model or exported by the user. */ +public final class ConversationTimeline { + private ConversationTimeline() {} + + public static final class Operation { + public final ToolCall call; + public final ToolResult result; + Operation(ToolCall call, ToolResult result) { this.call = call; this.result = result; } + } + + public static final class Block { + public final String id; + public final String text; + public final boolean reasoning; + public final List operations; + public final List steps; + private Block(String id, String text, boolean reasoning, List operations) { + this(id, text, reasoning, operations, Collections.emptyList()); + } + private Block(String id, String text, boolean reasoning, List operations, List steps) { + this.id = id; this.text = text; this.reasoning = reasoning; + this.operations = Collections.unmodifiableList(new ArrayList<>(operations)); + this.steps = Collections.unmodifiableList(new ArrayList<>(steps)); + } + public boolean isTools() { return !operations.isEmpty(); } + public boolean isAgent() { + if (operations.size() != 1) return false; + ToolDisplayCategory category = ToolCallUtils.getDisplayCategory(operations.get(0).call.getName()); + return category == ToolDisplayCategory.AGENT || category == ToolDisplayCategory.AGENT_PIPELINE; + } + } + + public static final class Row { + public final ChatMessage first; + public final List messages; + public final List process; + public final ChatMessage answer; + public final boolean isTurn; + public final boolean running; + public final boolean pending; + public final long processingStartedAt; + public final long processingFinishedAt; + private Row(List messages) { + this.messages = Collections.unmodifiableList(new ArrayList<>(messages)); + first = messages.get(0); + boolean hasProcess = false, active = false, awaiting = false; + long startedAt = 0, finishedAt = 0; + for (ChatMessage message : messages) { + hasProcess |= message.hasToolCalls() || message.isRetryNotice() || message.isError(); + if (message.getProcessingStartedAt() > 0) { + startedAt = startedAt == 0 ? message.getProcessingStartedAt() : Math.min(startedAt, message.getProcessingStartedAt()); + finishedAt = Math.max(finishedAt, message.getProcessingFinishedAt()); + } + active |= message.isStreaming(); + for (ToolCall call : message.getToolCalls()) { + ToolResult result = message.getToolResult(call.getId()); + awaiting |= result != null && "pending".equals(result.getReviewState()); + active |= result == null || "running".equals(result.getReviewState()); + } + } + isTurn = hasProcess && first.getRole() == ChatMessage.Role.ASSISTANT && !first.isCompactBlock(); + ChatMessage last = messages.get(messages.size() - 1); + answer = isTurn && !last.hasToolCalls() && !last.isRetryNotice() && !last.isError() + && (last.getProcessingStartedAt() == 0 || last.getProcessingFinishedAt() > 0) + && !last.getContent().trim().isEmpty() ? last : null; + running = active; + pending = awaiting; + processingStartedAt = startedAt; + processingFinishedAt = finishedAt; + ArrayList blocks = new ArrayList<>(); + ArrayList group = new ArrayList<>(); + ArrayList steps = new ArrayList<>(); + for (ChatMessage message : messages) { + boolean hasProse = message != answer && !message.getContent().trim().isEmpty(); + if (hasProse) flush(blocks, group, steps); + if (message != answer && !message.getReasoningContent().trim().isEmpty()) { + Block reasoning = new Block(message.getId() + ":reasoning", message.getReasoningContent(), true, Collections.emptyList()); + // Internal reasoning is part of the work, not a new outward assistant reply. + if (group.isEmpty()) { + if (hasProse) blocks.add(reasoning); + } + else steps.add(reasoning); + } + if (hasProse) { + blocks.add(new Block(message.getId() + ":text", message.getContent(), false, Collections.emptyList())); + } + for (ToolCall call : message.getToolCalls()) { + Operation operation = new Operation(call, message.getToolResult(call.getId())); + Block callBlock = new Block("call:" + call.getId(), "", false, Collections.singletonList(operation)); + if (callBlock.isAgent()) { + flush(blocks, group, steps); + blocks.add(callBlock); + continue; + } + group.add(operation); + steps.add(callBlock); + } + } + flush(blocks, group, steps); + process = Collections.unmodifiableList(blocks); + } + } + + private static void flush(List blocks, ArrayList group, ArrayList steps) { + if (group.isEmpty()) return; + blocks.add(new Block("tools:" + group.get(0).call.getId(), "", false, group, steps)); + group.clear(); steps.clear(); + } + + public static List build(List visibleMessages) { + ArrayList rows = new ArrayList<>(); + ArrayList turn = new ArrayList<>(); + for (ChatMessage message : visibleMessages) { + if (message.isHidden() || message.getRole() == ChatMessage.Role.TOOL || message.getRole() == ChatMessage.Role.SYSTEM) continue; + if (message.getRole() != ChatMessage.Role.ASSISTANT || message.isCompactBlock() || message.isModelSwitchNotification()) { + flushTurn(rows, turn); + rows.add(new Row(Collections.singletonList(message))); + } else { + turn.add(message); + } + } + flushTurn(rows, turn); + return rows; + } + + private static void flushTurn(List rows, ArrayList turn) { + if (turn.isEmpty()) return; + Row row = new Row(turn); + if (row.isTurn) rows.add(row); + else for (ChatMessage message : turn) rows.add(new Row(Collections.singletonList(message))); + turn.clear(); + } +} diff --git a/app/src/main/java/cn/lineai/ui/model/ProcessingDuration.java b/app/src/main/java/cn/lineai/ui/model/ProcessingDuration.java new file mode 100644 index 00000000..05c625d4 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/model/ProcessingDuration.java @@ -0,0 +1,14 @@ +package cn.lineai.ui.model; + +/** Compact elapsed time for a single processing section. */ +public final class ProcessingDuration { + private ProcessingDuration() {} + + public static String format(long elapsedMillis) { + long seconds = Math.max(0, elapsedMillis) / 1000; + if (seconds < 60) return seconds + "s"; + long minutes = seconds / 60; + if (minutes < 60) return minutes + "m " + seconds % 60 + "s"; + return minutes / 60 + "h " + minutes % 60 + "m " + seconds % 60 + "s"; + } +} diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..4a1f37ba --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,2 @@ + +#171819#E5E9EEfalse diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c769ce5a..30752832 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1150,7 +1150,7 @@ Авто - Автоматически выполнять включённые инструменты; опасные инструменты подтверждаются по политике + Автоматически выполнять включённые инструменты без отдельного подтверждения Подтверждение Опасные операции требуют подтверждения перед выполнением Только чтение @@ -1209,4 +1209,288 @@ Тестовые данные — состояния не отражают реальные запуски инструментов. Руководство От новичка до профи: модели, рабочее пространство, Termux, SSH и MCP - \ No newline at end of file + + + + SkillHub вернул ошибку: + В ответе списка SkillHub отсутствуют данные + В ответе детальной информации SkillHub отсутствует skill + Содержимое файла SkillHub слишком большое + Загрузка SkillHub не является действительным ZIP + Ответ иконки SkillHub не является изображением + Недействительный URL иконки SkillHub + Документ Skill SkillHub слишком большой + Недействительный ID комментария + Ответ SkillHub слишком большой + SkillHub вернул недействительный JSON + SkillHub вернул недействительный slug + Небезопасный slug SkillHub + Недействительный путь к файлу SkillHub + Недействительная версия SkillHub + Пример %1$d + Ответ аккаунта SkillHub слишком большой + Недействительная сессия SkillHub + Информация об аккаунте SkillHub неполная + Пожалуйста, сначала войдите в аккаунт SkillHub + Комментарий должен содержать от 1 до 500 символов + Недействительная версия Skill + Локальная директория Skill не существует + В локальном Skill отсутствует SKILL.md + Невозможно прочитать локальную директорию Skill + Путь к файлу Skill выходит за границы + Skill содержит конфиденциальный файл: + Файл Skill слишком большой: + Общий размер файлов Skill превышает 10 МБ + Количество файлов Skill превышает 200 + Запрос на публикацию Skill слишком большой + Невозможно закодировать параметр SkillHub + Пожалуйста, выберите локальный Skill из приложения или текущего проекта + Не удалось получить аккаунт SkillHub + Не удалось опубликовать комментарий + Не удалось ответить на комментарий + Не удалось поставить лайк комментарию + Не удалось убрать лайк с комментария + Не удалось удалить комментарий + Не удалось получить статус избранного + Не удалось добавить в избранное + Не удалось удалить из избранного + Содержимое запроса SkillHub слишком большое + Не удалось выйти из аккаунта SkillHub + Не удалось опубликовать Skill + Имя Skill не может быть пустым и не может превышать 100 символов + (HTTP %1$d) + + + Магазин Skills + Откройте для себя community Skills + Просматривайте, проверяйте и устанавливайте community возможности от SkillHub + Поиск по имени, описанию или автору + Популярные загрузки + Самые избранные + Недавно обновлённые + Сортировать по %s + Аккаунт SkillHub + Проверка аккаунта SkillHub… + Вход через официальную страницу SkillHub + Центр возможностей SkillHub + Проверка статуса аккаунта не удалась + Нажмите здесь, чтобы повторить + Войти в аккаунт SkillHub + Войдите, чтобы публиковать, комментировать и добавлять в избранное Skills + Вход выполнен в SkillHub + вход выполнен + Аккаунт SkillHub подключён + Продолжить + Выйти + Выход… + Вы вышли из аккаунта SkillHub + Предыдущая + Следующая + Загрузка страницы %d… + Загрузка не удалась, нажмите здесь, чтобы повторить + Подходящие Skills не найдены + Страница %d · Всего %s Skills + Подтверждено + Требуется API Key + , посмотреть детали + %.1f万 + Неизвестная ошибка + Детали Skill + Безопасно + Копировать промпт + Удалить из избранного + В избранное + Поделиться + Полные возможности + Обзор + Файлы · %d + Комментарии · %d + Версии + Оценка + Предпросмотр + Информация о Skill + Категория + Источник + Обновлено + Подкатегории + Теги + SKILL.md + Сведите или разведите пальцы для изменения размера шрифта документа + Для этой версии нет публичного документа Skill + Проверка безопасности + Проверьте содержимое Skill перед использованием + Явных рисков безопасности не обнаружено + Содержит scripts/ или файлы скриптов; установка только сохраняет на диск, без автоматического выполнения. + Может потребоваться настройка API Key, не записывайте ключи в файлы Skill. + Список файлов · %d файлов + Нажмите на файл для предпросмотра публичного текстового содержимого + Для этой версии нет публичных файлов + Предпросмотр %s + Корень + Этот тип файла не поддерживает текстовый предпросмотр + Загрузка файла… + Закрыть + Копировать + Содержимое файла скопировано + Комментарии сообщества + Опубликовать комментарий + Пока нет публичных комментариев, войдите, чтобы оставить первый + Ответить %s + Комментарии требуют проверки SkillHub после публикации, максимум 500 символов. + Поделитесь своим опытом… + Напишите ответ… + Отмена + Отправить комментарий + Отправка… + Комментарий отправлен, будет показан после проверки + Пользователь SkillHub + Убрать лайк + Лайк + Ответить + Все ответы %d + Удалить + История версий + История версий отсутствует + Отчёт об оценке SkillHub + Для этого Skill нет публичного отчёта об оценке + Общая оценка %.1f + Для этого Skill нет публичных примеров использования + Пользователь: + + Выберите местоположение и установите + Установить %s + Пожалуйста, сначала войдите в аккаунт SkillHub + Успешно добавлено в избранное + Успешно удалено из избранного + Промпт установки скопирован + Установить Skill из SkillHub: %1$s, версия %2$s. Проверьте файлы и отчёт о безопасности перед установкой; не выполняйте его скрипты автоматически. + Можно открывать только HTTPS ссылки + Выберите область установки Skill + %d файлов + Местоположение установки + Глобальные Skills приложения + Доступно во всех рабочих пространствах + Текущий проект + .linecode/skills · только текущее рабочее пространство + Содержит файлы скриптов. Установка только сохраняет на диск и никогда не выполняет автоматически; проверьте содержимое перед использованием. + Этот Skill может требовать API Key; не записывайте ключи в файлы Skill. + Также может потребоваться настройка API Key. + Установка… + Установлено + Skill успешно установлен + Не удалось загрузить детали + Сообщество SkillHub + Официальный вход в SkillHub + Учётные данные отправляются только на официальную страницу SkillHub, LineCode не читает коды подтверждения или пароли. + Автоматический возврат в магазин после официального входа + Официальная страница входа в SkillHub + Вход выполнен %s + Вход в SkillHub выполнен успешно + Опубликовать Skill + Файлы из выбранной директории Skill будут загружены. Перед публикацией личные ключи, файлы .env, учётные данные и содержимое слишком большого размера будут отклонены; аутентификация SkillHub и другие правила всё равно проверяются официальным сервисом. + Выбрать локальный Skill + Slug + например: my-skill + Отображаемое имя + Имя Skill + Версия + например: 1.0.0 + Опубликовать в SkillHub + Нет доступных локальных Skills для публикации + Публикация… + Skill успешно опубликован + Центр возможностей SkillHub + Просматривайте, устанавливайте, комментируйте и добавляйте в избранное Skills с помощью нативного UI LineCode; возможности аккаунта, создателя, предприятия и платформы выполняются на ограниченной официальной странице SkillHub. + Аккаунт и социальное + Профиль + Профиль, опубликованный контент и обзор аккаунта + Моё избранное + Просмотреть все избранные Skills + Подписки + Просмотреть подписанных создателей и обновления + Уведомления + Комментарии, обзоры и уведомления платформы + Настройки аккаунта + Аватар, привязки, уведомления и безопасность аккаунта + Подтверждение личности + Официальное подтверждение требуется для публикации и возможностей предприятия + API Token + Создание, просмотр и отзыв токенов платформы + Создатель + Центр создателя + Управление публикацией, статусом проверки, версиями и апелляциями + Официальный рабочий стол публикации + Иконка, импорт GitHub, заявка и публикация версий + Обнаружить + SkillSet + Просмотр комбинаций Skills и тематических пакетов + MCP Server + Поиск и просмотр MCP Servers + Skill Hunt + Рейтинги, голосование и звания + Конкурс + Работы конкурса, рейтинги и награды + Площадь предприятия + Главная страница предприятия, популярные Skills и подписки + Предприятие и платформа + Панель управления предприятия + Skills команды, участники, обзоры и ключи + Публикация предприятия + Публикация и поддержка Skills предприятия + Управление торговцем + Соглашения, статус торговца и ключи разработчика + Панель администратора + Доступно только для аккаунтов администраторов SkillHub + Обзор Skill + Доступно только для аккаунтов с правами проверки + Эта возможность предоставляется официальной страницей SkillHub. Сессии отправляются только на разрешённые официальные домены. + Полные детали SkillHub + Недействительная запись возможности SkillHub + Мой профиль + Настройки аккаунта + Подтверждение личности + Моё избранное + Подписки + Уведомления + Центр создателя + Публикация + Конкурс + Площадь предприятия + Панель управления предприятия + Публикация предприятия + Управление торговцем + Панель администратора + Обзор Skill + Неизвестная ошибка + Ещё %d в очереди + Пользовательский + Онлайн магазин + Магазин Skills SkillHub + Просматривайте, ищите и безопасно устанавливайте community Skills + Добавить в диалог + Файлы проекта + Режим · %1$s + Рабочая область + Настройки + Отправить сообщение + Остановить генерацию + Обработано + Обработка + Ожидание подтверждения + Инструментов использовано: %1$d + Файлов изменено: %1$d + Просмотр + Один раз + Всегда + Отклонить + Разрешить эту операцию в текущей рабочей области? + Терминал + Всегда разрешать эту точную команду в текущей рабочей области и среде выполнения + Отозвать сохранённые разрешения команд + Сохранённые разрешения команд отозваны + Начните здесь. + Расскажите LineCode, что вы хотите сделать. + Развернуть + Свернуть + Другие действия + diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml index 1f8d6442..350be28b 100644 --- a/app/src/main/res/values-v27/styles.xml +++ b/app/src/main/res/values-v27/styles.xml @@ -1,6 +1,6 @@ diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index d1a6ee70..e63e9396 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1153,7 +1153,7 @@ 自动 - 自动执行已启用工具,危险工具按策略确认 + 自动执行已启用工具,无需逐次确认 确认 危险操作需要确认后执行 只读 @@ -1212,4 +1212,288 @@ 以下为模拟数据,状态不代表真实工具运行结果。 查看教程 从零到进阶:模型配置、工作区、Termux、SSH 与 MCP + + + + SkillHub 返回错误: + SkillHub 列表响应缺少 data + SkillHub 详情响应缺少 skill + SkillHub 文件内容过大 + SkillHub 下载内容不是有效 ZIP + SkillHub 图标响应不是图片 + 无效的 SkillHub 图标地址 + SkillHub Skill 文档过大 + 无效的评论 ID + SkillHub 响应过大 + SkillHub 返回了无效 JSON + SkillHub 返回了无效 slug + 无效的 SkillHub slug + 无效的 SkillHub 文件路径 + 无效的 SkillHub 版本 + 示例 %1$d + SkillHub 账号响应过大 + 无效的 SkillHub 会话 + SkillHub 账号信息不完整 + 请先登录 SkillHub 账号 + 评论内容应为 1-500 字符 + 无效的 Skill 版本 + 本地 Skill 目录不存在 + 本地 Skill 缺少 SKILL.md + 无法读取本地 Skill 目录 + Skill 文件路径越界 + Skill 包含敏感文件: + Skill 文件过大: + Skill 文件总大小超过 10 MB + Skill 文件数量超过 200 个 + Skill 发布请求过大 + 无法编码 SkillHub 参数 + 请选择 App 或当前项目中的本地 Skill + 获取 SkillHub 账号失败 + 发表评论失败 + 回复评论失败 + 点赞评论失败 + 取消点赞失败 + 删除评论失败 + 获取收藏状态失败 + 收藏失败 + 取消收藏失败 + SkillHub 请求内容过大 + 退出 SkillHub 账号失败 + 发布 Skill 失败 + Skill 名称不能为空且不能超过 100 字 + (HTTP %1$d) + + + Skill 商店 + 发现社区 Skills + 浏览、检查并安装来自 SkillHub 的社区能力 + 搜索名称、描述或作者 + 热门下载 + 最多收藏 + 最近更新 + 按 %s 排序 + SkillHub 账号 + 正在检查 SkillHub 账号… + 登录由 SkillHub 官方页面处理 + SkillHub 功能中心 + 账号状态检查失败 + 点此重试 + 登录 SkillHub 账号 + 登录后可发布、评论和收藏 Skill + SkillHub 已登录 + 已登录 + SkillHub 账号已连接 + 继续使用 + 退出登录 + 正在退出… + 已退出 SkillHub 账号 + 上一页 + 下一页 + 正在加载第 %d 页… + 加载失败,点此重试 + 没有找到匹配的 Skill + 第 %d 页 · 共 %s 个 Skill + 已认证 + 需要 API Key + ,查看详情 + %.1f万 + 未知错误 + Skill 详情 + 安全 + 复制 Prompt + 取消收藏 + 收藏 + 分享 + 完整功能 + 概述 + 文件 · %d + 评论 · %d + 版本 + 评测报告 + 效果预览 + Skill 信息 + 分类 + 来源 + 更新 + 子分类 + 标签 + SKILL.md + 双指缩放可调整文档字号 + 该版本暂无公开 Skill 文档 + 安全检查 + 需要在使用前检查 Skill 内容 + 未发现明确的安全风险 + 包含 scripts/ 或脚本文件;安装只会落盘,不会自动执行。 + 可能需要自行配置 API Key,请勿将密钥写入 Skill 文件。 + 文件清单 · %d 个 + 点击文件可预览公开文本内容 + 该版本没有公开文件 + 预览 %s + 根目录 + 该文件类型不支持文本预览 + 正在加载文件… + 关闭 + 复制 + 文件内容已复制 + 社区评论 + 发表评论 + 暂无公开评论,登录后可发表第一条评论 + 回复 %s + 评论提交后需经过 SkillHub 审核,最多 500 字。 + 分享你的使用体验… + 写下回复… + 取消 + 提交评论 + 正在提交… + 评论已提交,审核通过后展示 + SkillHub 用户 + 取消赞 + + 回复 + 全部回复 %d + 删除 + 版本历史 + 暂无版本历史 + SkillHub 评测报告 + 该 Skill 暂无公开评测报告 + 综合评分 %.1f + 该 Skill 暂无公开使用示例 + 用户: + + 选择位置并安装 + 安装 %s + 请先登录 SkillHub 账号 + 收藏成功 + 已取消收藏 + 安装 Prompt 已复制 + 请从 SkillHub 安装 Skill:%1$s,版本 %2$s。安装前请检查文件与安全报告,不要自动执行其中的脚本。 + 仅允许打开 HTTPS 链接 + 选择 Skill 的安装范围 + %d 个文件 + 安装位置 + App 全局 Skills + 所有工作区均可使用 + 当前项目 + .linecode/skills · 仅当前工作区 + 包含脚本文件。安装只会落盘,不会自动执行;使用前请检查内容。 + 该 Skill 可能需要 API Key,请勿将密钥写入 Skill 文件。 + 可能还需要自行配置 API Key。 + 正在安装… + 已安装 + Skill 安装成功 + 详情加载失败 + SkillHub 社区 + SkillHub 官方登录 + 凭据仅提交到 SkillHub 官方页面,LineCode 不读取验证码或密码。 + 完成官方登录后将自动返回商店 + SkillHub 官方登录页面 + 已登录%s + SkillHub 登录成功 + 发布 Skill + 将上传所选 Skill 目录中的文件。发布前会拒绝私钥、.env、凭据文件及超限内容;SkillHub 的实名认证等规则仍由官方服务校验。 + 选择本地 Skill + Slug + 例如:my-skill + 显示名称 + Skill 名称 + 版本 + 例如:1.0.0 + 发布到 SkillHub + 没有可发布的本地 Skill + 正在发布… + Skill 发布成功 + SkillHub 功能中心 + 浏览、安装、评论和收藏使用 LineCode 原生界面;账号、创作者、企业及平台功能在受限 SkillHub 官方页面中完成。 + 账号与社交 + 个人中心 + 资料、已发布内容和账号概览 + 我的收藏 + 查看全部已收藏 Skill + 我的关注 + 查看关注的创作者与动态 + 通知中心 + 评论、审核和平台通知 + 账号设置 + 头像、绑定、通知与账号安全 + 实名认证 + 发布和企业功能所需的官方认证 + API Token + 创建、查看和撤销平台 Token + 创作者 + 创作者中心 + 管理发布、审核状态、版本和申诉 + 官方发布工作台 + 图标、GitHub 导入、认领和版本发布 + 发现 + SkillSet + 浏览 Skill 组合与主题包 + MCP Server + 搜索和查看 MCP Server + Skill Hunt + 榜单、投票与称号 + 赛事 + 赛事作品、排名和获奖信息 + 企业广场 + 企业主页、热门 Skill 和关注 + 企业与平台 + 企业工作台 + 团队 Skill、成员、审核和密钥 + 企业发布 + 发布和维护企业 Skill + 商户管理 + 协议、商户状态和开发者密钥 + 管理后台 + 仅对 SkillHub 管理员账号开放 + Skill 审核 + 仅对有审核权限的账号开放 + 此功能由 SkillHub 官方页面提供,会话仅发送到允许的官方域名。 + SkillHub 完整详情 + 无效的 SkillHub 功能入口 + 个人中心 + 账号设置 + 实名认证 + 我的收藏 + 我的关注 + 通知中心 + 创作者中心 + 发布 Skill + 赛事 + 企业广场 + 企业工作台 + 企业发布 + 商户管理 + 管理后台 + Skill 审核 + 未知错误 + 还有 %d 条排队中 + 自定义 + 在线商店 + SkillHub Skill 商店 + 浏览、搜索并安全安装社区 Skill + 添加到对话 + 项目文件 + 模式 · %1$s + 工作区 + 设置 + 发送消息 + 停止生成 + 已处理 + 正在处理 + 等待确认 + 使用了 %1$d 个工具 + 已编辑 %1$d 个文件 + 审阅 + 允许一次 + 永久允许 + 拒绝 + 允许在当前工作区执行这次操作吗? + 终端 + 永久允许当前工作区及执行目标中的这条完整命令 + 撤销已保存的命令许可 + 已撤销保存的命令许可 + 从这里,开始。 + 把想做的事,交给 LineCode。 + 展开 + 收起 + 更多操作 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 056ebe79..8e527a20 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,2 @@ - - #FF000000 - #FF30D158 - +#FCFCFD#333B46true diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e8e0125..71e3a2aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1152,7 +1152,7 @@ Auto - Automatically run enabled tools; dangerous tools are confirmed per policy + Automatically run enabled tools without individual confirmation Confirm Dangerous operations require confirmation before running Read-only @@ -1211,4 +1211,288 @@ Sample data — states shown below may not reflect real tool runs. Tutorial Beginner to advanced: models, workspace, Termux, SSH and MCP + + + + SkillHub returned error: + SkillHub list response missing data + SkillHub detail response missing skill + SkillHub file content too large + SkillHub download is not a valid ZIP + SkillHub icon response is not an image + Invalid SkillHub icon URL + SkillHub Skill document too large + Invalid comment ID + SkillHub response too large + SkillHub returned invalid JSON + SkillHub returned invalid slug + Invalid SkillHub slug + Invalid SkillHub file path + Invalid SkillHub version + Example %1$d + SkillHub account response too large + Invalid SkillHub session + SkillHub account information incomplete + Please log in to SkillHub account first + Comment should be 1-500 characters + Invalid Skill version + Local Skill directory does not exist + Local Skill missing SKILL.md + Cannot read local Skill directory + Skill file path out of bounds + Skill contains sensitive file: + Skill file too large: + Total Skill files size exceeds 10 MB + Skill file count exceeds 200 + Skill publish request too large + Cannot encode SkillHub parameter + Please select a local Skill from App or current project + Failed to get SkillHub account + Failed to post comment + Failed to reply to comment + Failed to like comment + Failed to unlike comment + Failed to delete comment + Failed to get star status + Failed to star + Failed to unstar + SkillHub request content too large + Failed to logout from SkillHub account + Failed to publish Skill + Skill name cannot be empty and cannot exceed 100 characters + (HTTP %1$d) + + + Skill Store + Discover community Skills + Browse, inspect and install community capabilities from SkillHub + Search name, description or author + Hot downloads + Most starred + Recently updated + Sort by %s + SkillHub account + Checking SkillHub account… + Login handled by SkillHub official page + SkillHub feature center + Account status check failed + Click here to retry + Log in to SkillHub account + Log in to publish, comment and star Skills + SkillHub logged in + logged in + SkillHub account connected + Continue + Log out + Logging out… + Logged out from SkillHub account + Previous + Next + Loading page %d… + Load failed, click here to retry + No matching Skills found + Page %d · %s Skills total + Verified + Requires API Key + , view details + %.1fw + Unknown error + Skill Details + Safe + Copy Prompt + Unstar + Star + Share + Full features + Overview + Files · %d + Comments · %d + Versions + Evaluation + Preview + Skill Information + Category + Source + Updated + Subcategories + Tags + SKILL.md + Pinch to zoom to adjust document font size + No public Skill document for this version + Safety Check + Check Skill content before use + No obvious security risks found + Contains scripts/ or script files; installation only saves to disk, no auto-execution. + May require configuring API Key, do not write keys into Skill files. + File List · %d files + Click file to preview public text content + No public files for this version + Preview %s + Root + This file type does not support text preview + Loading file… + Close + Copy + File content copied + Community Comments + Post Comment + No public comments yet, log in to post the first one + Reply to %s + Comments require SkillHub review after submission, max 500 characters. + Share your experience… + Write reply… + Cancel + Submit Comment + Submitting… + Comment submitted, will be shown after review + SkillHub user + Unlike + Like + Reply + All replies %d + Delete + Version History + No version history available + SkillHub Evaluation Report + No public evaluation report for this Skill + Overall score %.1f + No public usage examples for this Skill + User: + + Select location and install + Install %s + Please log in to SkillHub account first + Starred successfully + Unstarred successfully + Install prompt copied + Install Skill from SkillHub: %1$s, version %2$s. Check the files and security report before installing; do not auto-execute its scripts. + Only HTTPS links can be opened + Select the Skill install scope + %d files + Install location + App global Skills + Available in all workspaces + Current project + .linecode/skills · current workspace only + Contains script files. Installation only saves to disk and never auto-executes; check contents before use. + This Skill may require an API Key; do not write keys into Skill files. + May also need to configure an API Key. + Installing… + Installed + Skill installed successfully + Failed to load details + SkillHub Community + SkillHub Official Login + Credentials are only submitted to SkillHub official page, LineCode does not read verification codes or passwords. + Will automatically return to store after official login + SkillHub official login page + Logged in%s + SkillHub login successful + Publish Skill + Files from the selected Skill directory will be uploaded. Before publishing, private keys, .env, credential files and oversized content will be rejected; SkillHub authentication and other rules are still validated by the official service. + Select local Skill + Slug + e.g.: my-skill + Display name + Skill name + Version + e.g.: 1.0.0 + Publish to SkillHub + No publishable local Skills available + Publishing… + Skill published successfully + SkillHub Feature Center + Browse, install, comment and star Skills using LineCode native UI; account, creator, enterprise and platform features are completed in the restricted SkillHub official page. + Account & Social + Profile + Profile, published content and account overview + My Stars + View all starred Skills + Following + View followed creators and updates + Notifications + Comments, reviews and platform notifications + Account Settings + Avatar, bindings, notifications and account security + Identity Verification + Official verification required for publishing and enterprise features + API Token + Create, view and revoke platform tokens + Creator + Creator Center + Manage publishing, review status, versions and appeals + Official Publish Workbench + Icon, GitHub import, claiming and version publishing + Discover + SkillSet + Browse Skill combinations and theme packs + MCP Server + Search and view MCP Servers + Skill Hunt + Rankings, voting and titles + Contest + Contest works, rankings and awards + Enterprise Square + Enterprise homepage, popular Skills and following + Enterprise & Platform + Enterprise Dashboard + Team Skills, members, reviews and keys + Enterprise Publish + Publish and maintain enterprise Skills + Merchant Management + Agreements, merchant status and developer keys + Admin Panel + Only open to SkillHub admin accounts + Skill Review + Only open to accounts with review permissions + This feature is provided by the official SkillHub page. Sessions are only sent to allowed official domains. + SkillHub Full Details + Invalid SkillHub feature entry + My Profile + Account Settings + Identity Verification + My Stars + Following + Notifications + Creator Center + Publish + Contest + Enterprise Square + Enterprise Dashboard + Enterprise Publish + Merchant Management + Admin Panel + Skill Review + Unknown error + %d more queued + Custom + Online Store + SkillHub Skill Store + Browse, search and safely install community Skills + Add to conversation + Project files + Mode · %1$s + Workspace + Settings + Send message + Stop generation + Processed + Processing + Waiting for confirmation + Used %1$d tools + Edited %1$d files + Review + Allow once + Always allow + Reject + Allow this operation in the current workspace? + Terminal + Always allow this exact command in this workspace and execution target + Revoke saved command permissions + Saved command permissions revoked + Start here. + Tell LineCode what you want to do. + Expand + Collapse + More actions diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 472685c9..c4660990 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -8,7 +8,7 @@ @color/line_bg @color/line_bg @color/line_bg - false + @bool/line_light_system_bars diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/DiffLines.java b/tool-ui/src/main/java/cn/lineai/tool/ui/DiffLines.java new file mode 100644 index 00000000..27d4b69d --- /dev/null +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/DiffLines.java @@ -0,0 +1,67 @@ +package cn.lineai.tool.ui; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Bounded-memory line diff. Large replacement regions remain valid, non-minimal edits. */ +public final class DiffLines { + public static final class Line { + public final int kind; // -1 removed, 0 unchanged, 1 added + public final int number; + public final String text; + public final boolean terminated; + Line(int kind, int number, String text) { + this.kind = kind; this.number = number; this.terminated = text.endsWith("\n"); + this.text = terminated ? text.substring(0, text.length() - 1) : text; + } + } + public final List lines; + public final int added; + public final int removed; + private DiffLines(List lines) { + this.lines = Collections.unmodifiableList(lines); + int plus = 0, minus = 0; + for (Line line : lines) { if (line.kind == 1) plus++; else if (line.kind == -1) minus++; } + added = plus; removed = minus; + } + public static DiffLines calculate(String oldText, String newText) { + String[] a = split(oldText), b = split(newText); + int prefix = 0, suffix = 0; + while (prefix < a.length && prefix < b.length && a[prefix].equals(b[prefix])) prefix++; + while (suffix < a.length - prefix && suffix < b.length - prefix + && a[a.length - suffix - 1].equals(b[b.length - suffix - 1])) suffix++; + ArrayList lines = new ArrayList<>(); + for (int i = 0; i < prefix; i++) lines.add(new Line(0, i + 1, a[i])); + int m = a.length - prefix - suffix, n = b.length - prefix - suffix; + if ((long) (m + 1) * (n + 1) <= 1_000_000) { + int[][] lcs = new int[m + 1][n + 1]; + for (int i = m - 1; i >= 0; i--) for (int j = n - 1; j >= 0; j--) { + lcs[i][j] = a[prefix + i].equals(b[prefix + j]) ? 1 + lcs[i + 1][j + 1] : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + int i = 0, j = 0; + while (i < m || j < n) { + if (i < m && j < n && a[prefix + i].equals(b[prefix + j])) { + lines.add(new Line(0, prefix + j + 1, b[prefix + j])); i++; j++; + } else if (i < m && (j == n || lcs[i + 1][j] >= lcs[i][j + 1])) { + lines.add(new Line(-1, prefix + i + 1, a[prefix + i])); i++; + } else { lines.add(new Line(1, prefix + j + 1, b[prefix + j])); j++; } + } + } else { + for (int i = 0; i < m; i++) lines.add(new Line(-1, prefix + i + 1, a[prefix + i])); + for (int j = 0; j < n; j++) lines.add(new Line(1, prefix + j + 1, b[prefix + j])); + } + for (int j = b.length - suffix; j < b.length; j++) lines.add(new Line(0, j + 1, b[j])); + return new DiffLines(lines); + } + private static String[] split(String text) { + if (text == null || text.isEmpty()) return new String[0]; + // A terminating newline is not an extra empty source line. + String normalized = text.replace("\r\n", "\n"); + String[] lines = normalized.split("\n", -1); + boolean terminated = normalized.endsWith("\n"); + if (terminated) lines = java.util.Arrays.copyOf(lines, lines.length - 1); + for (int i = 0; i < lines.length; i++) if (i < lines.length - 1 || terminated) lines[i] += "\n"; + return lines; + } +} diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallBlockView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallBlockView.java index 9210998a..a7e8e745 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallBlockView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallBlockView.java @@ -16,6 +16,15 @@ public final class ToolCallBlockView extends LinearLayout { private ToolCallCardView childView; private String projectPath = ""; private ToolReviewListener toolReviewListener; + private String childIdentity = ""; + private java.util.Map expansionState; + private String expansionKey = ""; + + public void setExpansionState(java.util.Map state, String key) { + expansionState = state; + expansionKey = key; + if (childView instanceof ToolCallExpansion) ((ToolCallExpansion) childView).setExpansionState(state, key); + } public ToolCallBlockView(Context context) { this(context, ToolCallViewFactoryRegistry.getDefault()); @@ -44,9 +53,16 @@ public void bind(ToolCall toolCall, ToolResult result) { } String name = toolCall == null ? "" : toolCall.getName(); ToolDisplayCategory category = resolveDisplayCategory(name); + String identity = toolCall == null ? "" : toolCall.getId() + ":" + name; + if (childView != null && childIdentity.equals(identity)) { + childView.bind(toolCall, result); + return; + } + childIdentity = identity; childView = registry.createView(getContext(), resolveViewClass(name), category); if (childView != null) { removeAllViews(); + if (childView instanceof ToolCallExpansion) ((ToolCallExpansion) childView).setExpansionState(expansionState, expansionKey); childView.setToolReviewListener(toolReviewListener); childView.setProjectPath(projectPath); addView((View) childView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallExpansion.java b/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallExpansion.java new file mode 100644 index 00000000..fa9e88ba --- /dev/null +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallExpansion.java @@ -0,0 +1,8 @@ +package cn.lineai.tool.ui; + +import java.util.Map; + +/** Disclosure belongs to the conversation, so recycling and streamed results cannot reopen it. */ +public interface ToolCallExpansion { + void setExpansionState(Map state, String key); +} diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/util/ToolCallUtils.java b/tool-ui/src/main/java/cn/lineai/tool/ui/util/ToolCallUtils.java index 1d65ea62..c2657b7c 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/util/ToolCallUtils.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/util/ToolCallUtils.java @@ -22,7 +22,7 @@ public static ToolDisplayCategory getDisplayCategory(String name) { return ToolDisplayCategory.fallbackDisplayCategory(name); } - static JSONObject parseInput(ToolCall toolCall) { + public static JSONObject parseInput(ToolCall toolCall) { return ToolCallInputParser.parseInput(toolCall); } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/BaseToolCallView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/BaseToolCallView.java index 03d7ae8b..43e24471 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/BaseToolCallView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/BaseToolCallView.java @@ -39,7 +39,7 @@ public enum TerminalStatus { public BaseToolCallView(Context context) { super(context); setOrientation(LinearLayout.VERTICAL); - setBackground(LineTheme.roundedStroke(context, LineTheme.CODE_BG, 8, LineTheme.CODE_BORDER)); + setBackground(null); } /** @@ -121,7 +121,7 @@ protected TextView addMessageRow(LinearLayout parent, int iconType, String text, LinearLayout row = new LinearLayout(getContext()); row.setOrientation(HORIZONTAL); row.setGravity(Gravity.TOP); - LineTheme.padding(row, LineTheme.SM, LineTheme.XS, LineTheme.SM, LineTheme.XS); + LineTheme.padding(row, 12, 12, 12, 12); IconButtonView icon = new IconButtonView(getContext(), iconType); icon.setIconColor(color); diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/DiffView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/DiffView.java index f71d3de7..8d193bb4 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/DiffView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/DiffView.java @@ -1,134 +1,67 @@ package cn.lineai.tool.ui; -import cn.lineai.ui.theme.LineTheme; import android.content.Context; import android.graphics.Typeface; import android.view.Gravity; +import android.view.View; import android.widget.HorizontalScrollView; import android.widget.LinearLayout; import android.widget.TextView; -import cn.lineai.tool.ui.R; -import java.util.ArrayList; -import java.util.List; +import cn.lineai.ui.theme.LineTheme; public final class DiffView extends HorizontalScrollView { - private static final int MAX_LINES = 50; private final LinearLayout content; - public DiffView(Context context) { - super(context); - setHorizontalScrollBarEnabled(false); - setFillViewport(true); - content = new LinearLayout(context); - content.setOrientation(LinearLayout.VERTICAL); - addView(content, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + super(context); setFillViewport(true); setHorizontalScrollBarEnabled(true); + content = new LinearLayout(context); content.setOrientation(LinearLayout.VERTICAL); + addView(content, new LayoutParams(-2, -2)); } - - public void bind(String oldContent, String newContent) { + public void bind(String before, String after) { bind(DiffLines.calculate(before, after)); } + public void bind(DiffLines diff) { content.removeAllViews(); - List lines = computeDiff(oldContent == null ? "" : oldContent, newContent == null ? "" : newContent); - int displayCount = Math.min(MAX_LINES, lines.size()); - for (int i = 0; i < displayCount; i++) { - content.addView(lineView(lines.get(i)), new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + int displayed = 0, omitted = 0; + boolean[] visible = new boolean[diff.lines.size()]; + for (int i = 0; i < diff.lines.size(); i++) if (diff.lines.get(i).kind != 0) { + for (int j = Math.max(0, i - 3); j < Math.min(visible.length, i + 4); j++) visible[j] = true; } - if (lines.size() > MAX_LINES) { - TextView truncated = LineTheme.text(getContext(), getContext().getString(R.string.tool_call_diff_truncated, lines.size()), - LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.ITALIC); - truncated.setTypeface(Typeface.MONOSPACE, Typeface.ITALIC); - LineTheme.padding(truncated, LineTheme.SM, LineTheme.SM, LineTheme.SM, LineTheme.SM); - content.addView(truncated, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - } - } - - private LinearLayout lineView(DiffLine line) { - LinearLayout row = new LinearLayout(getContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setMinimumWidth(getResources().getDisplayMetrics().widthPixels - LineTheme.dp(getContext(), 64)); - if (line.type == DiffLine.ADD) { - row.setBackgroundColor(LineTheme.DIFF_ADD_BG); - } else if (line.type == DiffLine.REMOVE) { - row.setBackgroundColor(LineTheme.DIFF_DEL_BG); - } - LineTheme.padding(row, LineTheme.SM, 1, LineTheme.SM, 1); - - row.addView(codeCell(line.oldLine > 0 ? String.valueOf(line.oldLine) : "", LineTheme.TEXT_TERTIARY, 28, Gravity.END)); - row.addView(codeCell(line.newLine > 0 ? String.valueOf(line.newLine) : "", LineTheme.TEXT_TERTIARY, 28, Gravity.END)); - int textColor = line.type == DiffLine.ADD ? LineTheme.DIFF_ADD_TEXT - : line.type == DiffLine.REMOVE ? LineTheme.DIFF_DEL_TEXT - : LineTheme.TEXT; - String prefix = line.type == DiffLine.ADD ? "+" : line.type == DiffLine.REMOVE ? "-" : " "; - row.addView(codeCell(prefix, textColor, 12, Gravity.START)); - row.addView(codeCell(line.content, textColor, -1, Gravity.START)); - return row; - } - - private TextView codeCell(String value, int color, int widthDp, int gravity) { - TextView view = LineTheme.text(getContext(), value, LineTheme.FONT_XS, color, Typeface.NORMAL); - view.setTypeface(Typeface.MONOSPACE); - view.setGravity(gravity); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - widthDp > 0 ? LineTheme.dp(getContext(), widthDp) : LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ); - params.rightMargin = LineTheme.dp(getContext(), widthDp > 0 ? 4 : 0); - view.setLayoutParams(params); - return view; - } - - private List computeDiff(String oldText, String newText) { - String[] oldLines = oldText.split("\n", -1); - String[] newLines = newText.split("\n", -1); - int m = oldLines.length; - int n = newLines.length; - int[][] dp = new int[m + 1][n + 1]; - for (int i = 1; i <= m; i++) { - for (int j = 1; j <= n; j++) { - dp[i][j] = oldLines[i - 1].equals(newLines[j - 1]) - ? dp[i - 1][j - 1] + 1 - : Math.max(dp[i - 1][j], dp[i][j - 1]); + for (int i = 0; i < diff.lines.size(); i++) { + if (!visible[i] && diff.added + diff.removed > 0) { omitted++; continue; } + if (displayed >= 200) { + TextView more = LineTheme.text(getContext(), getContext().getString(R.string.tool_call_diff_truncated, diff.lines.size()), 12, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LineTheme.padding(more, 14, 12, 14, 12); content.addView(more); break; } - } - - ArrayList reversed = new ArrayList<>(); - int i = m; - int j = n; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldLines[i - 1].equals(newLines[j - 1])) { - reversed.add(new DiffLine(DiffLine.CONTEXT, oldLines[i - 1], i, j)); - i--; - j--; - } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) { - reversed.add(new DiffLine(DiffLine.ADD, newLines[j - 1], 0, j)); - j--; - } else { - reversed.add(new DiffLine(DiffLine.REMOVE, oldLines[i - 1], i, 0)); - i--; + if (omitted > 0) { addGap(); omitted = 0; } + DiffLines.Line line = diff.lines.get(i); + content.addView(lineView(line), new LinearLayout.LayoutParams(-1, -2)); displayed++; + if (!line.terminated) { + TextView note = LineTheme.text(getContext(), getContext().getString(R.string.tool_call_diff_no_newline), 12, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LineTheme.padding(note, 14, 4, 14, 4); content.addView(note, new LinearLayout.LayoutParams(-1, -2)); } } - - ArrayList lines = new ArrayList<>(reversed.size()); - for (int k = reversed.size() - 1; k >= 0; k--) { - lines.add(reversed.get(k)); - } - return lines; + if (omitted > 0) addGap(); } - - private static final class DiffLine { - static final int ADD = 1; - static final int REMOVE = 2; - static final int CONTEXT = 3; - - final int type; - final String content; - final int oldLine; - final int newLine; - - DiffLine(int type, String content, int oldLine, int newLine) { - this.type = type; - this.content = content == null ? "" : content; - this.oldLine = oldLine; - this.newLine = newLine; - } + private void addGap() { + TextView gap = LineTheme.text(getContext(), "⋯", 13, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + LineTheme.padding(gap, 18, 4, 0, 4); content.addView(gap, new LinearLayout.LayoutParams(-1, -2)); + } + private View lineView(DiffLines.Line line) { + LinearLayout row = new LinearLayout(getContext()); row.setGravity(Gravity.CENTER_VERTICAL); + row.setMinimumHeight(LineTheme.dp(getContext(), 26)); + int color = line.kind > 0 ? LineTheme.DIFF_ADD_TEXT : line.kind < 0 ? LineTheme.DIFF_DEL_TEXT : LineTheme.TEXT_SECONDARY; + if (line.kind != 0) row.setBackgroundColor(line.kind > 0 ? LineTheme.DIFF_ADD_BG : LineTheme.DIFF_DEL_BG); + View marker = new View(getContext()); + marker.setBackgroundColor(line.kind == 0 ? android.graphics.Color.TRANSPARENT : line.kind > 0 ? LineTheme.SUCCESS : LineTheme.DANGER); + row.addView(marker, new LinearLayout.LayoutParams(LineTheme.dp(getContext(), 3), -1)); + TextView number = cell(String.valueOf(line.number), color); number.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + LineTheme.padding(number, 2, 3, 10, 3); + row.addView(number, new LinearLayout.LayoutParams(LineTheme.dp(getContext(), 42), -1)); + String text = line.text.length() > 2000 ? line.text.substring(0, 2000) + "…" : line.text; + TextView code = cell(text, color); LineTheme.padding(code, 4, 3, 14, 3); + row.addView(code, new LinearLayout.LayoutParams(-2, -2)); + return row; + } + private TextView cell(String text, int color) { + TextView view = LineTheme.text(getContext(), text, 13, color, Typeface.NORMAL); + view.setTypeface(Typeface.MONOSPACE); view.setSingleLine(true); return view; } } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallDeleteView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallDeleteView.java index 891412df..b2d4e097 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallDeleteView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallDeleteView.java @@ -1,238 +1,73 @@ package cn.lineai.tool.ui; -import cn.lineai.tool.ToolCallCardView; -import cn.lineai.tool.ToolReviewListener; -import cn.lineai.model.tool.ToolCall; -import cn.lineai.model.tool.ToolResult; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; -import android.app.AlertDialog; import android.content.Context; import android.graphics.Typeface; import android.view.Gravity; -import android.view.View; import android.widget.LinearLayout; import android.widget.TextView; -import cn.lineai.tool.ui.R; +import cn.lineai.model.tool.ToolCall; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.tool.ToolCallCardView; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.ui.theme.BoundedScrollView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import org.json.JSONArray; import org.json.JSONObject; -public final class ToolCallDeleteView extends BaseToolCallView implements ToolCallCardView { - private ToolReviewListener toolReviewListener; +/** Deletion details are disclosed here; the active request is confirmed in the bottom composer slot. */ +public final class ToolCallDeleteView extends BaseToolCallView implements ToolCallCardView, ToolCallExpansion { + private ToolCall call; + private ToolResult result; private String projectPath = ""; - - public ToolCallDeleteView(Context context) { - super(context); - setBackground(LineTheme.roundedStroke(context, LineTheme.DANGER_MUTED, 8, LineTheme.DANGER_MUTED_2)); - } - - public void setToolReviewListener(ToolReviewListener listener) { - toolReviewListener = listener; + private String key = "delete"; + private Map expansion = new HashMap<>(); + public ToolCallDeleteView(Context context) { super(context); setBackground(null); } + @Override public void setToolReviewListener(ToolReviewListener listener) {} + @Override public void setProjectPath(String path) { projectPath = path == null ? "" : path; } + @Override public void setExpansionState(Map state, String key) { + if (state != null) expansion = state; + this.key = key; if (call != null) render(); } - - public void setProjectPath(String projectPath) { - this.projectPath = projectPath == null ? "" : projectPath; - } - - public void bind(ToolCall toolCall, ToolResult result) { + @Override public void bind(ToolCall call, ToolResult result) { this.call = call; this.result = result; render(); } + private void render() { removeAllViews(); - JSONObject input = ToolCallUtils.parseInput(toolCall); - ArrayList paths = paths(input); - String reason = input.optString("reason").trim(); - if (reason.length() == 0) { - reason = getContext().getString(R.string.tool_call_delete_no_reason); - } - String state = result == null ? "pending" : result.getReviewState(); - boolean pending = result == null || state.length() == 0 || "pending".equals(state); - boolean accepted = "accepted".equals(state); - boolean rejected = "rejected".equals(state); - boolean complete = result != null && result.getContent().length() > 0 && !pending && !rejected; - boolean error = result != null && result.isError(); - - addHeader(reason, description(paths.size(), pending, accepted, rejected, complete, error), pending || rejected || error); - addPathList(paths); - if (pending) { - addActions(toolCall, reason, paths); - } else if (accepted && result != null && result.getContent().length() == 0) { - addStateMessage(getContext().getString(R.string.tool_call_delete_pending), LineTheme.WARNING); - } else if (rejected) { - addStateMessage(result == null || result.getContent().length() == 0 ? getContext().getString(R.string.tool_call_delete_rejected) : result.getContent(), LineTheme.DANGER); - } else if (result != null && result.getContent().length() > 0) { - addStateMessage(result.getContent(), error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); - } - } - - private void addHeader(String reason, String description, boolean danger) { - LinearLayout header = new LinearLayout(getContext()); - header.setOrientation(HORIZONTAL); - header.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(header, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - - IconButtonView icon = new IconButtonView(getContext(), IconButtonView.TRASH_2); - icon.setIconColor(LineTheme.DANGER); - icon.setIconSizeDp(30, 15); - icon.setClickable(false); - icon.setBackground(LineTheme.roundedStroke(getContext(), android.graphics.Color.TRANSPARENT, 8, LineTheme.DANGER_MUTED_2)); - header.addView(icon, new LayoutParams(LineTheme.dp(getContext(), 30), LineTheme.dp(getContext(), 30))); - - LinearLayout labels = new LinearLayout(getContext()); - labels.setOrientation(VERTICAL); - LayoutParams labelParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - labelParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - header.addView(labels, labelParams); - - TextView title = LineTheme.text(getContext(), reason, LineTheme.FONT_SM, danger ? LineTheme.DANGER : LineTheme.TEXT, Typeface.BOLD); - title.setSingleLine(false); - labels.addView(title, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - TextView desc = LineTheme.text(getContext(), description, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - LinearLayout.LayoutParams descParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - descParams.topMargin = LineTheme.dp(getContext(), 2); - labels.addView(desc, descParams); - addView(header, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - - private void addPathList(ArrayList paths) { - View divider = new View(getContext()); - divider.setBackgroundColor(LineTheme.DANGER_MUTED_2); - addView(divider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - - LinearLayout list = new LinearLayout(getContext()); - list.setOrientation(VERTICAL); - LineTheme.padding(list, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - if (paths.isEmpty()) { - list.addView(pathRow(getContext().getString(R.string.tool_call_delete_no_path)), new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } else { - for (String path : paths) { - list.addView(pathRow(path), new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - } - addView(list, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - - private TextView pathRow(String path) { - TextView row = LineTheme.text(getContext(), "- " + displayPath(path), LineTheme.FONT_XS, LineTheme.TEXT, Typeface.NORMAL); - row.setTypeface(Typeface.MONOSPACE); - row.setSingleLine(false); - row.setTextIsSelectable(true); - LineTheme.padding(row, 0, 2, 0, 2); - return row; - } - - private void addActions(ToolCall toolCall, String reason, ArrayList paths) { - View divider = new View(getContext()); - divider.setBackgroundColor(LineTheme.DANGER_MUTED_2); - addView(divider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - - LinearLayout row = new LinearLayout(getContext()); - row.setOrientation(HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(row, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - - LinearLayout reject = button(IconButtonView.CLOSE, getContext().getString(R.string.tool_call_delete_reject), LineTheme.DANGER, LineTheme.SURFACE_LIGHT, LineTheme.DANGER_MUTED_2); - reject.setOnClickListener(v -> { - if (toolReviewListener != null) { - toolReviewListener.onToolReview(toolCall == null ? "" : toolCall.getId(), "rejected", ""); - } - }); - row.addView(reject, new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(getContext(), 32))); - - View spacer = new View(getContext()); - row.addView(spacer, new LayoutParams(0, 1, 1f)); - - LinearLayout accept = button(IconButtonView.TRASH_2, getContext().getString(R.string.tool_call_delete_confirm), LineTheme.TEXT_ON_COLOR, LineTheme.DANGER, LineTheme.DANGER); - accept.setOnClickListener(v -> showConfirmDialog(toolCall, reason, paths)); - row.addView(accept, new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(getContext(), 32))); - addView(row, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - - private LinearLayout button(int iconType, String label, int color, int background, int border) { - LinearLayout button = new LinearLayout(getContext()); - button.setOrientation(HORIZONTAL); - button.setGravity(Gravity.CENTER); - button.setBackground(LineTheme.roundedStroke(getContext(), background, 6, border)); - button.setClickable(true); - LineTheme.padding(button, LineTheme.SM, 2, LineTheme.SM, 2); - IconButtonView icon = new IconButtonView(getContext(), iconType); - icon.setIconColor(color); - icon.setIconSizeDp(18, 12); - icon.setClickable(false); - button.addView(icon, new LayoutParams(LineTheme.dp(getContext(), 18), LineTheme.dp(getContext(), 18))); - TextView text = LineTheme.text(getContext(), label, LineTheme.FONT_XS, color, Typeface.BOLD); - LayoutParams textParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - textParams.leftMargin = LineTheme.dp(getContext(), 2); - button.addView(text, textParams); - return button; - } - - private void showConfirmDialog(ToolCall toolCall, String reason, ArrayList paths) { - StringBuilder message = new StringBuilder(); - message.append(reason).append("\n\n"); + JSONObject input = ToolCallUtils.parseInput(call); + ArrayList paths = new ArrayList<>(); JSONArray array = input.optJSONArray("paths"); + if (array != null) for (int i = 0; i < array.length(); i++) paths.add(array.optString(i)); + for (String name : new String[]{"file_path", "path"}) if (!input.optString(name).isEmpty()) paths.add(input.optString(name)); + String state = result == null ? "running" : result.getReviewState(); + boolean failed = result != null && result.isError(); + int status = "pending".equals(state) ? R.string.tool_call_status_pending_review + : "running".equals(state) || "accepted".equals(state) && result.getContent().isEmpty() ? R.string.tool_call_status_running + : failed ? R.string.tool_call_status_failed : R.string.tool_call_status_done; + LinearLayout header = new LinearLayout(getContext()); header.setGravity(Gravity.CENTER_VERTICAL); header.setMinimumHeight(dp(48)); + IconButtonView icon = new IconButtonView(getContext(), IconButtonView.TRASH_2); icon.setIconSizeDp(24, 16); + icon.setIconColor(failed ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); icon.setClickable(false); + header.addView(icon, new LayoutParams(dp(24), dp(32))); + TextView label = LineTheme.text(getContext(), getContext().getString(R.string.common_delete) + " · " + getContext().getString(status) + + getContext().getString(R.string.tool_call_delete_count_suffix, paths.size()), 14, failed ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LayoutParams labelParams = new LayoutParams(0, -2, 1); labelParams.leftMargin = dp(6); header.addView(label, labelParams); + boolean open = Boolean.TRUE.equals(expansion.get(key)); + IconButtonView arrow = new IconButtonView(getContext(), open ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + arrow.setIconSizeDp(24, 14); arrow.setIconColor(LineTheme.TEXT_SECONDARY); arrow.setClickable(false); + header.addView(arrow, new LayoutParams(dp(24), dp(32))); + header.setFocusable(true); header.setOnClickListener(v -> { expansion.put(key, !open); render(); }); addView(header, new LayoutParams(-1, -2)); + if (!open) return; + StringBuilder value = new StringBuilder(input.optString("reason").trim()); for (String path : paths) { - message.append("- ").append(displayPath(path)).append('\n'); - } - new AlertDialog.Builder(getContext()) - .setTitle(getContext().getString(R.string.tool_call_delete_confirm_title)) - .setMessage(message.toString().trim()) - .setNegativeButton(getContext().getString(R.string.common_cancel), null) - .setPositiveButton(getContext().getString(R.string.tool_call_delete_confirm_title), (dialog, which) -> { - if (toolReviewListener != null) { - toolReviewListener.onToolReview(toolCall == null ? "" : toolCall.getId(), "accepted", ""); - } - }) - .show(); - } - - private void addStateMessage(String text, int color) { - View divider = new View(getContext()); - divider.setBackgroundColor(LineTheme.DANGER_MUTED_2); - addView(divider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - TextView message = LineTheme.text(getContext(), text, LineTheme.FONT_XS, color, Typeface.NORMAL); - message.setSingleLine(false); - LineTheme.padding(message, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - addView(message, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - - private String description(int count, boolean pending, boolean accepted, boolean rejected, boolean complete, boolean error) { - String status = error ? getContext().getString(R.string.tool_call_shell_failed) : pending ? getContext().getString(R.string.tool_call_delete_status_pending) : rejected ? getContext().getString(R.string.tool_call_delete_status_rejected) : complete ? getContext().getString(R.string.tool_call_delete_status_executed) : accepted ? getContext().getString(R.string.tool_call_delete_status_accepted) : getContext().getString(R.string.common_delete); - return status + getContext().getString(R.string.tool_call_delete_count_suffix, count); - } - - private String displayPath(String path) { - String value = path == null ? "" : path.trim(); - if (projectPath.length() == 0 || value.length() == 0) { - return value; - } - if (value.equals(projectPath)) { - return "."; - } - String prefix = projectPath.endsWith("/") ? projectPath : projectPath + "/"; - if (value.startsWith(prefix)) { - return value.substring(prefix.length()); - } - return value; - } - - private ArrayList paths(JSONObject input) { - ArrayList values = new ArrayList<>(); - JSONArray array = input.optJSONArray("paths"); - if (array != null) { - for (int i = 0; i < array.length(); i++) { - String value = array.optString(i).trim(); - if (value.length() > 0) { - values.add(value); - } - } - } - String filePath = input.optString("file_path").trim(); - if (filePath.length() > 0) { - values.add(filePath); - } - String path = input.optString("path").trim(); - if (path.length() > 0) { - values.add(path); + if (value.length() > 0) value.append('\n'); + value.append(ToolCallUtils.workspaceDisplayPath(projectPath, path)); } - return values; + if (failed && !result.getContent().isEmpty()) value.append("\n\n").append(result.getContent()); + TextView details = LineTheme.text(getContext(), value.toString(), 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + details.setTextIsSelectable(true); details.setLineSpacing(dp(7), 1); LineTheme.padding(details, 14, 12, 14, 12); + BoundedScrollView scroll = new BoundedScrollView(getContext(), 200); + scroll.setBackground(LineTheme.roundedStroke(getContext(), LineTheme.CODE_BG, 12, LineTheme.CODE_BORDER)); + scroll.addView(details, new android.widget.ScrollView.LayoutParams(-1, -2)); addView(scroll, new LayoutParams(-1, -2)); } + private int dp(int value) { return LineTheme.dp(getContext(), value); } } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallGenericView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallGenericView.java index 77abc605..674d7051 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallGenericView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallGenericView.java @@ -1,181 +1,73 @@ package cn.lineai.tool.ui; -import cn.lineai.tool.ToolCallCardView; -import cn.lineai.tool.ToolReviewListener; -import cn.lineai.model.tool.ToolCall; -import cn.lineai.model.tool.ToolResult; -import cn.lineai.ui.theme.BoundedScrollView; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; - import android.content.Context; import android.graphics.Typeface; import android.view.Gravity; -import android.view.View; import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.ScrollView; import android.widget.TextView; -import cn.lineai.tool.ui.R; -import cn.lineai.tool.ToolDisplayCategory; -import org.json.JSONObject; +import cn.lineai.model.tool.ToolCall; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.tool.ToolCallCardView; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.ui.theme.BoundedScrollView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.util.Map; -public final class ToolCallGenericView extends BaseToolCallView implements ToolCallCardView { +/** Unknown and extension tools share the same manual disclosure as built-in tools. */ +public final class ToolCallGenericView extends BaseToolCallView implements ToolCallCardView, ToolCallExpansion { private final String label; - private TextView outputTextView; - private TerminalStatus lastTerminalStatus; - + private Map expansion; + private String key = ""; + private boolean open; + private ToolCall call; + private ToolResult result; public ToolCallGenericView(Context context, String label) { - super(context); - this.label = label == null || label.length() == 0 ? getContext().getString(R.string.tool_call_generic_mcp) : label; + super(context); this.label = label == null ? "" : label; } - - public void bind(ToolCall toolCall, ToolResult result) { - removeAllViews(); - outputTextView = null; - String name = toolCall == null ? "" : toolCall.getName(); - JSONObject input = ToolCallUtils.parseInput(toolCall); - // 简化进度圈逻辑:直接根据结果决定最终状态 - TerminalStatus status = computeTerminalStatus(result); - lastTerminalStatus = status; - boolean running = status == TerminalStatus.RUNNING; - boolean error = status == TerminalStatus.FAILED; - boolean unknown = status == TerminalStatus.UNKNOWN; - boolean hasResult = result != null && result.getContent().length() > 0; - int statusColor = error ? LineTheme.DANGER - : (status == TerminalStatus.SUCCESS) ? LineTheme.SUCCESS - : unknown ? LineTheme.TEXT_TERTIARY - : LineTheme.ACCENT; - - LinearLayout header = new LinearLayout(getContext()); - header.setOrientation(HORIZONTAL); - header.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(header, LineTheme.SM, LineTheme.XS, LineTheme.SM, LineTheme.XS); - - IconButtonView icon = new IconButtonView(getContext(), iconFor(name)); - icon.setIconColor(statusColor); - icon.setIconSizeDp(24, 12); - icon.setClickable(false); - header.addView(icon, new LayoutParams(LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); - - LinearLayout titleBlock = new LinearLayout(getContext()); - titleBlock.setOrientation(VERTICAL); - LayoutParams titleParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - titleParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - titleParams.rightMargin = LineTheme.dp(getContext(), LineTheme.SM); - header.addView(titleBlock, titleParams); - titleBlock.addView(LineTheme.text(getContext(), label, 10, LineTheme.TEXT_TERTIARY, Typeface.BOLD), - new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - TextView nameView = LineTheme.text(getContext(), name, LineTheme.FONT_SM, error ? LineTheme.DANGER : LineTheme.TEXT, Typeface.NORMAL); - nameView.setTypeface(Typeface.MONOSPACE); - nameView.setSingleLine(true); - titleBlock.addView(nameView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - if (running) { - ProgressBar bar = new ProgressBar(getContext()); - bar.setIndeterminate(true); - header.addView(bar, new LayoutParams(LineTheme.dp(getContext(), 18), LineTheme.dp(getContext(), 18))); - } else { - // 工具调用结束:成功 → CHECK;失败 → CLOSE;未知 → CLOCK_3 表示等待结果 - int doneIcon = error ? IconButtonView.CLOSE - : (status == TerminalStatus.SUCCESS) ? IconButtonView.CHECK - : IconButtonView.CLOCK_3; - IconButtonView done = new IconButtonView(getContext(), doneIcon); - done.setIconColor(statusColor); - done.setIconSizeDp(18, 13); - done.setClickable(false); - header.addView(done, new LayoutParams(LineTheme.dp(getContext(), 18), LineTheme.dp(getContext(), 18))); - } - addView(header, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - String inputText = ToolCallUtils.prettyJson(input); - if (!"{}".equals(inputText)) { - addSection(getContext().getString(R.string.tool_call_input), inputText, LineTheme.TEXT_SECONDARY, 2); - } - if (hasResult) { - // If content is structured agent progress but we fell through to generic - // (missing registry), prefer human output over raw JSON dump. - outputTextView = addSection( - running ? getContext().getString(R.string.tool_call_progress) - : getContext().getString(R.string.tool_call_output), - outputDisplayText(result, running, error), - error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY, - running ? 3 : 8); - } - } - - private String outputDisplayText(ToolResult result, boolean running, boolean error) { - String rawContent = result == null ? "" : result.getContent(); - String displayContent = AgentToolResultDisplay.progressPayload(rawContent) != null - ? AgentToolResultDisplay.displayOutput(rawContent) - : rawContent; - if (displayContent == null || displayContent.trim().length() == 0) { - displayContent = error - ? getContext().getString(R.string.tool_call_agent_failed) - : getContext().getString(R.string.tool_call_agent_done); - } - return displayContent; + @Override public void setExpansionState(Map state, String key) { + expansion = state; this.key = key; open = state != null && Boolean.TRUE.equals(state.get(key)); } - - @Override - public void updateContent(ToolCall toolCall, ToolResult result) { - TerminalStatus status = computeTerminalStatus(result); - if (outputTextView == null || status != lastTerminalStatus) { - bind(toolCall, result); - return; - } - boolean running = status == TerminalStatus.RUNNING; + @Override public void bind(ToolCall call, ToolResult result) { + this.call = call; this.result = result; removeAllViews(); boolean error = result != null && result.isError(); - outputTextView.setText(outputDisplayText(result, running, error)); - } - - @Override - public void setToolReviewListener(ToolReviewListener listener) { - // Generic view does not use tool review - } - - @Override - public void setProjectPath(String projectPath) { - // Generic view does not use project path - } - - private int iconFor(String name) { - ToolDisplayCategory category = ToolCallUtils.getDisplayCategory(name); - if (category == ToolDisplayCategory.SHELL) return IconButtonView.TERMINAL; - if (ToolCallUtils.isCustomMcpTool(name)) return IconButtonView.MCP; - if (category == ToolDisplayCategory.DELETE) return IconButtonView.TRASH_2; - return IconButtonView.MCP; - } - - private TextView addSection(String title, String content, int color, int maxHeightRows) { - View divider = new View(getContext()); - divider.setBackgroundColor(LineTheme.CODE_BORDER); - addView(divider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - - LinearLayout section = new LinearLayout(getContext()); - section.setOrientation(VERTICAL); - LineTheme.padding(section, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - section.addView(LineTheme.text(getContext(), title, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.BOLD), - new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - TextView text = LineTheme.text(getContext(), content, LineTheme.FONT_XS, color, Typeface.NORMAL); - text.setTypeface(Typeface.MONOSPACE); - text.setTextIsSelectable(true); - LayoutParams textParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - textParams.topMargin = LineTheme.dp(getContext(), 4); - if (maxHeightRows > 4) { - BoundedScrollView scroll = new BoundedScrollView(getContext(), 220); - scroll.setFillViewport(false); - scroll.setBackground(LineTheme.roundedStroke(getContext(), LineTheme.SURFACE, 8, LineTheme.CODE_BORDER)); - LineTheme.padding(scroll, LineTheme.SM, LineTheme.SM, LineTheme.SM, LineTheme.SM); - scroll.addView(text, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - LayoutParams scrollParams = new LayoutParams(LayoutParams.MATCH_PARENT, LineTheme.dp(getContext(), 220)); - scrollParams.topMargin = LineTheme.dp(getContext(), 4); - section.addView(scroll, scrollParams); - } else { - text.setMaxLines(maxHeightRows); - section.addView(text, textParams); + LinearLayout header = new LinearLayout(getContext()); header.setGravity(Gravity.CENTER_VERTICAL); + header.setMinimumHeight(LineTheme.dp(getContext(),48)); + LineTheme.padding(header,0,12,0,12); header.setBackground(LineTheme.pressable(getContext())); + IconButtonView icon = new IconButtonView(getContext(),IconButtonView.MCP); + icon.setIconColor(error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); icon.setIconSizeDp(16,16); icon.setClickable(false); + header.addView(icon,new LayoutParams(LineTheme.dp(getContext(),16),LineTheme.dp(getContext(),16))); + String name = call == null ? label : call.getName(); + int status = error ? R.string.tool_call_status_failed : isTerminal(result) ? R.string.tool_call_status_done : R.string.tool_call_status_running; + TextView title = LineTheme.text(getContext(), getContext().getString(status) + " " + name,14,error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY,Typeface.NORMAL); + title.setMaxLines(2); title.setEllipsize(android.text.TextUtils.TruncateAt.END); + LayoutParams tp = new LayoutParams(0,-2,1); tp.leftMargin=LineTheme.dp(getContext(),10);header.addView(title,tp); + IconButtonView arrow = new IconButtonView(getContext(),open ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + arrow.setIconSizeDp(24,14); arrow.setIconColor(LineTheme.TEXT_SECONDARY); arrow.setClickable(false); + header.addView(arrow,new LayoutParams(LineTheme.dp(getContext(),24),LineTheme.dp(getContext(),24))); + header.setOnClickListener(v -> {open=!open;if(expansion!=null)expansion.put(key,open);bind(this.call,this.result);}); + addView(header,new LayoutParams(-1,-2)); + if (!open) return; + LinearLayout content = new LinearLayout(getContext());content.setOrientation(VERTICAL); + content.setBackground(LineTheme.rounded(getContext(),LineTheme.INPUT_BG,12));LineTheme.padding(content,16,16,16,16); + String input = ToolCallUtils.prettyJson(ToolCallUtils.parseInput(call)); + if (!"{}".equals(input)) section(content,R.string.tool_call_input,input,LineTheme.TEXT_SECONDARY); + if (result != null && !result.getContent().isEmpty()) { + String raw=result.getContent(); + String output=AgentToolResultDisplay.progressPayload(raw)!=null ? AgentToolResultDisplay.displayOutput(raw) : raw; + section(content,R.string.tool_call_output,output,error?LineTheme.DANGER:LineTheme.TEXT); } - addView(section, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - return text; + BoundedScrollView scroll = new BoundedScrollView(getContext(),280);scroll.addView(content,new android.widget.ScrollView.LayoutParams(-1,-2)); + addView(scroll,new LayoutParams(-1,-2)); + } + private void section(LinearLayout parent,int title,String value,int color) { + TextView heading = LineTheme.text(getContext(),getContext().getString(title),13,LineTheme.TEXT_SECONDARY,Typeface.NORMAL); + LineTheme.padding(heading,0,8,0,8); parent.addView(heading); + String preview=value==null?"":value.length()>65536?value.substring(0,65536)+"…":value; + TextView body=LineTheme.text(getContext(),preview,13,color,Typeface.NORMAL); + body.setTypeface(Typeface.MONOSPACE);body.setTextIsSelectable(true);body.setLineSpacing(LineTheme.dp(getContext(),6),1); + parent.addView(body,new LayoutParams(-1,-2)); } + @Override public void updateContent(ToolCall call,ToolResult result) {bind(call,result);} + @Override public void setToolReviewListener(ToolReviewListener listener) { } + @Override public void setProjectPath(String path) { } } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallReadView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallReadView.java index ec17389e..bd489fe3 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallReadView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallReadView.java @@ -1,179 +1,57 @@ package cn.lineai.tool.ui; -import cn.lineai.tool.ToolCallCardView; -import cn.lineai.tool.ToolReviewListener; -import cn.lineai.model.tool.ToolCall; -import cn.lineai.model.tool.ToolResult; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; import android.content.Context; import android.graphics.Typeface; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; +import android.text.TextUtils; import android.view.Gravity; -import android.view.View; -import android.widget.HorizontalScrollView; import android.widget.LinearLayout; import android.widget.TextView; -import cn.lineai.tool.ui.R; -import org.json.JSONObject; +import cn.lineai.model.tool.ToolCall; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.tool.ToolCallCardView; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +/** Read operations are status rows. File contents are deliberately not interactive. */ public final class ToolCallReadView extends BaseToolCallView implements ToolCallCardView { + private final TextView label; + private final IconButtonView icon; + private final ToolErrorView errorOutput; private String projectPath = ""; - private TextView messageTextView; - private TerminalStatus lastTerminalStatus; - private int lastContentMaxHeightDp; public ToolCallReadView(Context context) { super(context); - } - - @Override - public void setProjectPath(String projectPath) { - this.projectPath = projectPath == null ? "" : projectPath; - } - - @Override - public void setToolReviewListener(ToolReviewListener listener) { - // Read view does not use tool review - } - - @Override - public void bind(ToolCall toolCall, ToolResult result) { - removeAllViews(); - messageTextView = null; - String name = toolCall == null ? "" : toolCall.getName(); - JSONObject input = ToolCallUtils.parseInput(toolCall); - String label = ToolCallUtils.displayInputLabel(getContext(), name, input, projectPath); - // 简化进度圈逻辑:直接根据结果决定最终状态 - TerminalStatus status = computeTerminalStatus(result); - lastTerminalStatus = status; - boolean running = status == TerminalStatus.RUNNING; - boolean error = status == TerminalStatus.FAILED; - boolean unknown = status == TerminalStatus.UNKNOWN; - boolean complete = !running; - String actionLabel = actionLabel(name); - - LinearLayout header = new LinearLayout(getContext()); - header.setOrientation(HORIZONTAL); + setBackground(null); + LinearLayout header = new LinearLayout(context); header.setGravity(Gravity.CENTER_VERTICAL); - header.setMinimumHeight(LineTheme.dp(getContext(), 36)); - LineTheme.padding(header, LineTheme.SM, LineTheme.XS, LineTheme.SM, LineTheme.XS); - - int headerIconColor = error ? LineTheme.DANGER - : running ? LineTheme.ACCENT - : unknown ? LineTheme.TEXT_TERTIARY - : LineTheme.TEXT_SECONDARY; - IconButtonView icon = new IconButtonView(getContext(), iconFor(name)); - icon.setIconColor(headerIconColor); - icon.setIconSizeDp(24, 12); + header.setMinimumHeight(LineTheme.dp(context, 48)); + icon = new IconButtonView(context, IconButtonView.FILE); + icon.setIconSizeDp(24, 16); icon.setClickable(false); - header.addView(icon, new LayoutParams(LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); - - // 单行结构:动作标签(加粗)+ 目标路径,省掉第二行 - TextView path = new TextView(getContext()); - path.setTextSize(LineTheme.FONT_SM); - path.setIncludeFontPadding(false); - path.setTypeface(Typeface.MONOSPACE); - SpannableStringBuilder builder = new SpannableStringBuilder(); - int actionColor = error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY; - builder.append(actionLabel); - builder.setSpan(new StyleSpan(Typeface.BOLD), 0, actionLabel.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - builder.setSpan(new ForegroundColorSpan(actionColor), 0, actionLabel.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - builder.append(" "); - int pathStart = builder.length(); - builder.append(label); - builder.setSpan(new ForegroundColorSpan(error ? LineTheme.DANGER : LineTheme.TEXT), - pathStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - path.setText(builder); - path.setSingleLine(true); - path.setHorizontallyScrolling(true); - HorizontalScrollView pathScroll = horizontalPathScroll(path); - LayoutParams pathParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - pathParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - pathParams.rightMargin = LineTheme.dp(getContext(), LineTheme.SM); - header.addView(pathScroll, pathParams); - - View statusViewInstance = statusView(running); - if (complete && statusViewInstance instanceof IconButtonView) { - IconButtonView statusIcon = (IconButtonView) statusViewInstance; - if (error) { - statusIcon.setIconType(IconButtonView.CLOSE); - statusIcon.setIconColor(LineTheme.DANGER); - } else if (unknown) { - // 未知情况:使用时钟图标表示等待结果,颜色用 TEXT_TERTIARY - statusIcon.setIconType(IconButtonView.CLOCK_3); - statusIcon.setIconColor(LineTheme.TEXT_TERTIARY); - } - } - header.addView(statusViewInstance, new LayoutParams(LineTheme.dp(getContext(), 18), LineTheme.dp(getContext(), 18))); + icon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + header.addView(icon, new LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 32))); + label = LineTheme.text(context, "", 14, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + label.setSingleLine(true); + label.setEllipsize(TextUtils.TruncateAt.MIDDLE); + LayoutParams params = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1); + params.leftMargin = LineTheme.dp(context, 6); + header.addView(label, params); addView(header, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - if (running && result != null && result.getContent().length() > 0) { - messageTextView = addMessageRow(this, IconButtonView.LOADER, result.getContent(), LineTheme.TEXT_SECONDARY, - contentMaxHeightDp(result.getContent())); - lastContentMaxHeightDp = contentMaxHeightDp(result.getContent()); - } else if (error && result.getContent().length() > 0) { - messageTextView = addMessageRow(this, IconButtonView.CLOSE, result.getContent(), LineTheme.DANGER, - contentMaxHeightDp(result.getContent())); - lastContentMaxHeightDp = contentMaxHeightDp(result.getContent()); - } - } - - @Override - public void updateContent(ToolCall toolCall, ToolResult result) { - TerminalStatus status = computeTerminalStatus(result); - int maxHeightDp = contentMaxHeightDp(result == null ? "" : result.getContent()); - if (messageTextView == null || status != lastTerminalStatus || maxHeightDp != lastContentMaxHeightDp) { - bind(toolCall, result); - return; - } - messageTextView.setText(result == null ? "" : result.getContent()); - } - - private int contentMaxHeightDp(String content) { - if (content == null || content.trim().length() == 0) { - return 0; - } - int lines = 0; - int max = 0; - int n = content.length(); - for (int i = 0; i < n && lines <= 8; i++) { - if (content.charAt(i) == '\n') { - lines++; - max = 0; - } else { - max++; - if (max > 90) { - lines++; - max = 0; - } - } - } - return (content.length() > 300 || lines > 8) ? 220 : 0; - } - - private String actionLabel(String name) { - cn.lineai.tool.ui.ToolInfoResolver resolver = cn.lineai.tool.ui.ToolInfoResolverProvider.getDefault(); - if (resolver != null) { - String actionName = resolver.getActionName(getContext(), name); - if (actionName != null) { - return actionName; - } - } - return getContext().getString(R.string.tool_call_action_read); - } - - private int iconFor(String name) { - cn.lineai.tool.ui.ToolInfoResolver resolver = cn.lineai.tool.ui.ToolInfoResolverProvider.getDefault(); - if (resolver != null) { - int icon = resolver.getActionIcon(name); - if (icon != 0) { - return icon; - } - } - return IconButtonView.EXPAND; + errorOutput = new ToolErrorView(context); + addView(errorOutput, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + setClickable(false); + } + @Override public void setProjectPath(String path) { projectPath = path == null ? "" : path; } + @Override public void setToolReviewListener(ToolReviewListener listener) {} + @Override public void bind(ToolCall call, ToolResult result) { + String name = call == null ? "" : call.getName(); + String path = ToolCallUtils.displayInputLabel(getContext(), name, ToolCallUtils.parseInput(call), projectPath); + int status = result == null || "running".equals(result.getReviewState()) ? R.string.tool_call_status_running + : result.isError() ? R.string.tool_call_status_failed : R.string.tool_call_read_done; + label.setText(getContext().getString(status) + " " + path); + int color = result != null && result.isError() ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY; + label.setTextColor(color); icon.setIconColor(color); + errorOutput.bind(result); } } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallShellView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallShellView.java index 7127b0c1..c8444fdb 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallShellView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallShellView.java @@ -1,352 +1,83 @@ package cn.lineai.tool.ui; -import cn.lineai.tool.ToolCallCardView; -import cn.lineai.tool.ToolReviewListener; -import cn.lineai.model.tool.ToolCall; -import cn.lineai.model.tool.ToolResult; -import cn.lineai.ui.theme.BoundedScrollView; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; import android.content.Context; import android.graphics.Typeface; import android.text.TextUtils; import android.view.Gravity; -import android.view.View; import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.ScrollView; import android.widget.TextView; -import cn.lineai.tool.ui.R; -import org.json.JSONObject; - -public final class ToolCallShellView extends BaseToolCallView implements ToolCallCardView { - private static final int COLLAPSED_LINE_COUNT = 4; - private static final int EXPANDED_OUTPUT_LIMIT = 64 * 1024; - private static final int EXPANDED_HEAD_LIMIT = 24 * 1024; - private static final int EXPANDED_TAIL_LIMIT = 36 * 1024; - - private final IconButtonView terminalIcon; - private final TextView commandView; - private final ProgressBar progressBar; - private final LinearLayout viewCommandButton; - private final LinearLayout confirmSection; - private final View confirmDivider; - private final LinearLayout outputSection; - private final View outputDivider; - private final LinearLayout outputHeader; - private final IconButtonView statusIcon; - private final TextView outputTitle; - private final TextView outputMeta; - private final IconButtonView expandIcon; - private final TextView collapsedOutputView; - private final BoundedScrollView expandedScrollView; - private final TextView expandedOutputView; - private ToolReviewListener toolReviewListener; - private String toolCallId = ""; - private String command = ""; - private boolean expanded; - private boolean autoExpanded; - private boolean canExpand; +import cn.lineai.model.tool.ToolCall; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.tool.ToolCallCardView; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.ui.theme.BoundedScrollView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.util.HashMap; +import java.util.Map; + +public final class ToolCallShellView extends BaseToolCallView implements ToolCallCardView, ToolCallExpansion { + private final TextView label; + private final IconButtonView arrow; + private final TextView output; + private final BoundedScrollView detail; + private Map expansion = new HashMap<>(); + private String key = "shell"; + private String value = ""; public ToolCallShellView(Context context) { - super(context); - setBackground(LineTheme.rounded(context, LineTheme.CODE_BG, 6)); - LineTheme.padding(this, LineTheme.MD, LineTheme.XS, LineTheme.MD, LineTheme.XS); - - LinearLayout header = new LinearLayout(context); - header.setOrientation(HORIZONTAL); - header.setGravity(Gravity.CENTER_VERTICAL); - addView(header, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - terminalIcon = new IconButtonView(context, IconButtonView.TERMINAL); - terminalIcon.setIconSizeDp(14, 14); - terminalIcon.setClickable(false); - header.addView(terminalIcon, new LayoutParams(LineTheme.dp(context, 14), LineTheme.dp(context, 14))); - - commandView = LineTheme.text(context, "", LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - commandView.setTypeface(Typeface.MONOSPACE); - commandView.setSingleLine(true); - commandView.setEllipsize(TextUtils.TruncateAt.END); - commandView.setClickable(true); - commandView.setOnClickListener(v -> openFullCommand()); - LayoutParams commandParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - commandParams.leftMargin = LineTheme.dp(context, 6); - commandParams.rightMargin = LineTheme.dp(context, 6); - header.addView(commandView, commandParams); - - progressBar = new ProgressBar(context); - progressBar.setIndeterminate(true); - LayoutParams progressParams = new LayoutParams(LineTheme.dp(context, 18), LineTheme.dp(context, 18)); - progressParams.rightMargin = LineTheme.dp(context, LineTheme.SM); - header.addView(progressBar, progressParams); - - viewCommandButton = smallActionButton( - context, - IconButtonView.EXTERNAL_LINK, - getContext().getString(R.string.tool_call_shell_full), - LineTheme.SURFACE_LIGHT, - LineTheme.BORDER_LIGHT, - LineTheme.TEXT_SECONDARY, - true - ); - viewCommandButton.setOnClickListener(v -> openFullCommand()); - header.addView(viewCommandButton, new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 26))); - - confirmDivider = divider(context); - addView(confirmDivider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - confirmSection = buildConfirmSection(context); - addView(confirmSection, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - outputDivider = divider(context); - addView(outputDivider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - outputSection = new LinearLayout(context); - outputSection.setOrientation(VERTICAL); - addView(outputSection, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - outputHeader = new LinearLayout(context); - outputHeader.setOrientation(HORIZONTAL); - outputHeader.setGravity(Gravity.CENTER_VERTICAL); - outputHeader.setMinimumHeight(LineTheme.dp(context, 22)); - outputHeader.setClickable(true); - outputHeader.setOnClickListener(v -> { - if (canExpand) { - expanded = !expanded; - updateExpandedState(); - } - }); - outputSection.addView(outputHeader, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - statusIcon = new IconButtonView(context, IconButtonView.CHECK); - statusIcon.setIconSizeDp(12, 12); - statusIcon.setClickable(false); - outputHeader.addView(statusIcon, new LayoutParams(LineTheme.dp(context, 12), LineTheme.dp(context, 12))); - - outputTitle = LineTheme.text(context, "", LineTheme.FONT_XS, LineTheme.SUCCESS, Typeface.BOLD); - LayoutParams titleParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - titleParams.leftMargin = LineTheme.dp(context, 5); - outputHeader.addView(outputTitle, titleParams); - - outputMeta = LineTheme.text(context, "", LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - outputMeta.setGravity(Gravity.END); - LayoutParams metaParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - metaParams.leftMargin = LineTheme.dp(context, 5); - outputHeader.addView(outputMeta, metaParams); - - expandIcon = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); - expandIcon.setIconColor(LineTheme.TEXT_TERTIARY); - expandIcon.setIconSizeDp(13, 13); - expandIcon.setClickable(false); - outputHeader.addView(expandIcon, new LayoutParams(LineTheme.dp(context, 13), LineTheme.dp(context, 13))); - - collapsedOutputView = outputText(context); - LayoutParams collapsedParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - collapsedParams.topMargin = LineTheme.dp(context, 4); - outputSection.addView(collapsedOutputView, collapsedParams); - - expandedScrollView = new BoundedScrollView(context, 280); - expandedScrollView.setFillViewport(false); - expandedScrollView.setBackground(LineTheme.roundedStroke(context, LineTheme.SURFACE, 8, LineTheme.CODE_BORDER)); - LineTheme.padding(expandedScrollView, LineTheme.SM, LineTheme.SM, LineTheme.SM, LineTheme.SM); - expandedOutputView = outputText(context); - expandedScrollView.addView(expandedOutputView, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - LayoutParams expandedParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - expandedParams.topMargin = LineTheme.dp(context, 4); - outputSection.addView(expandedScrollView, expandedParams); - } - - public void bind(ToolCall toolCall, ToolResult result) { - JSONObject input = ToolCallUtils.parseInput(toolCall); - toolCallId = toolCall == null ? "" : toolCall.getId(); - command = input.optString("command", ""); - String reviewState = result == null ? "" : result.getReviewState(); - boolean pending = "pending".equals(reviewState); - boolean streaming = "running".equals(reviewState); - boolean error = result != null && result.isError(); - String content = result == null ? "" : result.getContent(); - String displayResult = streaming ? (content.length() == 0 ? getContext().getString(R.string.tool_call_shell_executing) : content) : content; - - if (streaming && !autoExpanded) { - expanded = true; - autoExpanded = true; - } - - int headerColor = error ? LineTheme.DANGER : streaming ? LineTheme.ACCENT : LineTheme.TEXT_TERTIARY; - terminalIcon.setIconColor(headerColor); - commandView.setText(command.length() == 0 ? cn.lineai.tool.ToolNames.SHELL_EXECUTE : command); - commandView.setTextColor(headerColor); - progressBar.setVisibility(streaming ? VISIBLE : GONE); - viewCommandButton.setVisibility(command.length() > 0 ? VISIBLE : GONE); - - confirmDivider.setVisibility(pending ? VISIBLE : GONE); - confirmSection.setVisibility(pending ? VISIBLE : GONE); - outputDivider.setVisibility(displayResult.length() > 0 ? VISIBLE : GONE); - outputSection.setVisibility(displayResult.length() > 0 ? VISIBLE : GONE); - if (displayResult.length() > 0) { - bindOutput(displayResult, streaming, error); - } - } - - @Override - public void setToolReviewListener(ToolReviewListener listener) { - toolReviewListener = listener; - } - - @Override - public void setProjectPath(String projectPath) { - // Shell view does not use project path - } - - private LinearLayout buildConfirmSection(Context context) { - LinearLayout section = new LinearLayout(context); - section.setOrientation(HORIZONTAL); - section.setGravity(Gravity.CENTER_VERTICAL); - LineTheme.padding(section, 0, LineTheme.XS, 0, 0); - - LinearLayout auto = smallActionButton(context, IconButtonView.ZAP, getContext().getString(R.string.tool_call_shell_auto_run), - LineTheme.SURFACE_LIGHT, LineTheme.BORDER_LIGHT, LineTheme.TEXT_SECONDARY, true); - auto.setMinimumWidth(LineTheme.dp(context, 78)); - auto.setOnClickListener(v -> review("session_auto")); - section.addView(auto, new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 30))); - - View spacer = new View(context); - section.addView(spacer, new LayoutParams(0, 1, 1f)); - - LinearLayout cancel = smallActionButton(context, IconButtonView.CLOSE, getContext().getString(R.string.tool_call_shell_skip), - LineTheme.CODE_BORDER, LineTheme.CODE_BORDER, LineTheme.TEXT_SECONDARY, false); - LayoutParams cancelParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 30)); - cancelParams.leftMargin = LineTheme.dp(context, LineTheme.SM); - cancel.setMinimumWidth(LineTheme.dp(context, 50)); - cancel.setOnClickListener(v -> review("rejected")); - section.addView(cancel, cancelParams); - - LinearLayout run = smallActionButton(context, IconButtonView.PLAY, getContext().getString(R.string.tool_call_shell_run), - LineTheme.ACCENT, LineTheme.ACCENT, LineTheme.TEXT_ON_COLOR, false); - LayoutParams runParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(context, 30)); - runParams.leftMargin = LineTheme.dp(context, LineTheme.SM); - run.setMinimumWidth(LineTheme.dp(context, 50)); - run.setOnClickListener(v -> review("accepted")); - section.addView(run, runParams); - return section; - } - - private void bindOutput(String displayResult, boolean streaming, boolean error) { - int lineCount = lineCount(displayResult); - canExpand = lineCount > COLLAPSED_LINE_COUNT || displayResult.length() > 240 || streaming; - int statusColor = error ? LineTheme.DANGER : LineTheme.SUCCESS; - statusIcon.setVisibility(streaming ? GONE : VISIBLE); - outputTitle.setVisibility(streaming ? GONE : VISIBLE); - statusIcon.setIconType(error ? IconButtonView.CIRCLE_ALERT : IconButtonView.CHECK); - statusIcon.setIconColor(statusColor); - outputTitle.setText(error ? getContext().getString(R.string.tool_call_shell_failed) : getContext().getString(R.string.tool_call_shell_completed)); - outputTitle.setTextColor(statusColor); - outputMeta.setText(getResources().getString(R.string.shell_output_line_count, lineCount)); - expandIcon.setVisibility(canExpand ? VISIBLE : GONE); - - collapsedOutputView.setText(collapsePreview(displayResult)); - collapsedOutputView.setTextColor(error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); - expandedOutputView.setText(expandedPreview(displayResult)); - expandedOutputView.setTextColor(error ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); - updateExpandedState(); - } - - private void updateExpandedState() { - expandIcon.setIconType(expanded ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); - expandIcon.setIconColor(LineTheme.TEXT_TERTIARY); - expandedScrollView.setVisibility(expanded ? VISIBLE : GONE); - collapsedOutputView.setVisibility(expanded ? GONE : VISIBLE); - } - - private void review(String state) { - if (toolReviewListener != null) { - toolReviewListener.onToolReview(toolCallId, state, ""); - } - } - - private void openFullCommand() { - if (toolReviewListener != null && command.length() > 0) { - toolReviewListener.onViewShellCommand(command); - } - } - - private LinearLayout smallActionButton( - Context context, - int iconType, - String label, - int backgroundColor, - int borderColor, - int textColor, - boolean stroke - ) { - LinearLayout button = new LinearLayout(context); - button.setOrientation(HORIZONTAL); - button.setGravity(Gravity.CENTER); - button.setClickable(true); - button.setBackground(stroke - ? LineTheme.roundedStroke(context, backgroundColor, 8, borderColor) - : LineTheme.rounded(context, backgroundColor, 8)); - LineTheme.padding(button, LineTheme.SM, 0, LineTheme.SM, 0); - - IconButtonView icon = new IconButtonView(context, iconType); - icon.setIconColor(textColor); - icon.setIconSizeDp(13, 13); - icon.setClickable(false); - button.addView(icon, new LayoutParams(LineTheme.dp(context, 13), LineTheme.dp(context, 13))); - - TextView text = LineTheme.text(context, label, LineTheme.FONT_XS, textColor, Typeface.BOLD); - LayoutParams textParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - textParams.leftMargin = LineTheme.dp(context, 3); - button.addView(text, textParams); - return button; - } - - private TextView outputText(Context context) { - TextView text = LineTheme.text(context, "", LineTheme.FONT_XS, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); - text.setTypeface(Typeface.MONOSPACE); - text.setLineSpacing(LineTheme.dp(context, 3), 1f); - text.setTextIsSelectable(true); - return text; - } - - private View divider(Context context) { - View divider = new View(context); - divider.setBackgroundColor(LineTheme.CODE_BORDER); - LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, 1); - params.topMargin = LineTheme.dp(context, LineTheme.XS); - divider.setLayoutParams(params); - return divider; + super(context); setBackground(null); + LinearLayout header = new LinearLayout(context); header.setGravity(Gravity.CENTER_VERTICAL); + header.setMinimumHeight(LineTheme.dp(context, 48)); + IconButtonView icon = new IconButtonView(context, IconButtonView.TERMINAL); + icon.setIconSizeDp(24, 16); icon.setIconColor(LineTheme.TEXT_SECONDARY); icon.setClickable(false); + icon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + header.addView(icon, new LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 32))); + label = LineTheme.text(context, "", 14, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + label.setSingleLine(true); label.setEllipsize(TextUtils.TruncateAt.END); + LayoutParams labelParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1); labelParams.leftMargin = LineTheme.dp(context, 6); + header.addView(label, labelParams); + arrow = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); arrow.setIconSizeDp(24, 14); + arrow.setIconColor(LineTheme.TEXT_SECONDARY); arrow.setClickable(false); + arrow.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + header.addView(arrow, new LayoutParams(LineTheme.dp(context, 24), LineTheme.dp(context, 32))); + header.setFocusable(true); header.setOnClickListener(v -> { expansion.put(key, !isExpanded()); renderExpansion(); }); + addView(header, new LayoutParams(-1, -2)); + detail = new BoundedScrollView(context, 240); + detail.setBackground(LineTheme.roundedStroke(context, LineTheme.CODE_BG, 12, LineTheme.CODE_BORDER)); + output = LineTheme.text(context, "", 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + output.setTypeface(Typeface.MONOSPACE); output.setLineSpacing(LineTheme.dp(context, 6), 1); + output.setTextIsSelectable(true); LineTheme.padding(output, 14, 12, 14, 12); + detail.addView(output, new android.widget.ScrollView.LayoutParams(-1, -2)); + addView(detail, new LayoutParams(-1, -2)); renderExpansion(); } - - private int lineCount(String value) { - if (value == null || value.length() == 0) { - return 0; + @Override public void bind(ToolCall call, ToolResult result) { + String command = ToolCallUtils.parseInput(call).optString("command", ""); + int status = result == null || "running".equals(result.getReviewState()) ? R.string.tool_call_status_running + : "pending".equals(result.getReviewState()) ? R.string.tool_call_status_pending_review + : result.isError() ? R.string.tool_call_status_failed : R.string.tool_call_status_done; + label.setText(getContext().getString(status) + " " + command); + label.setTextColor(result != null && result.isError() ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); + output.setTextColor(result != null && result.isError() ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); + String content = result == null || "pending".equals(result.getReviewState()) ? "" : result.getContent(); + if (content.length() > 64 * 1024) { + int omitted = content.length() - 60 * 1024; + content = content.substring(0, 24 * 1024) + "\n\n" + getContext().getString(R.string.tool_call_shell_folded, omitted) + + "\n\n" + content.substring(content.length() - 36 * 1024); } - return value.split("\\r?\\n", -1).length; + value = "$ " + command + (content.isEmpty() ? "" : "\n\n" + content); + renderExpansion(); } - - private String collapsePreview(String value) { - if (value == null || value.length() == 0) { - return ""; - } - String[] lines = value.split("\\r?\\n", -1); - int start = Math.max(0, lines.length - COLLAPSED_LINE_COUNT); - StringBuilder builder = new StringBuilder(); - for (int i = start; i < lines.length; i++) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(lines[i]); - } - String tail = builder.toString(); - return tail.length() > 320 ? tail.substring(tail.length() - 320) : tail; + @Override public void setExpansionState(Map state, String key) { + if (state != null) expansion = state; + this.key = key; renderExpansion(); } - - private String expandedPreview(String value) { - if (value == null || value.length() <= EXPANDED_OUTPUT_LIMIT) { - return value == null ? "" : value; - } - String head = value.substring(0, EXPANDED_HEAD_LIMIT); - String tail = value.substring(value.length() - EXPANDED_TAIL_LIMIT); - int folded = value.length() - head.length() - tail.length(); - return head + "\n\n" + getContext().getString(R.string.tool_call_shell_folded, folded) + "\n\n" + tail; + private boolean isExpanded() { return Boolean.TRUE.equals(expansion.get(key)); } + private void renderExpansion() { + boolean open = isExpanded(); detail.setVisibility(open ? VISIBLE : GONE); + arrow.setIconType(open ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + if (open && !value.contentEquals(output.getText())) output.setText(value); } + @Override public void setProjectPath(String path) {} + @Override public void setToolReviewListener(ToolReviewListener listener) {} } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallTodoView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallTodoView.java index 52c8e3a0..e9a5e6be 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallTodoView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallTodoView.java @@ -33,18 +33,26 @@ public ToolCallTodoView(Context context) { super(context); circleSize = LineTheme.dp(context, 14); circleStroke = LineTheme.dp(context, 2); - minRowHeight = LineTheme.dp(context, 28); + minRowHeight = LineTheme.dp(context, 44); } @Override public void bind(ToolCall toolCall, ToolResult result) { List items = parseItems(toolCall); - String signature = signature(items); + boolean failed = result != null && result.isError(); + String signature = signature(items) + "|" + failed + "|" + (failed ? result.getContent() : ""); if (signature.equals(lastSignature)) { return; } lastSignature = signature; lastItems = items; + if (failed) { + removeAllViews(); + ToolErrorView output = new ToolErrorView(getContext()); + output.bind(result); + addView(output, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + return; + } rebuild(items); } @@ -56,7 +64,7 @@ private void rebuild(List items) { } LinearLayout list = new LinearLayout(getContext()); list.setOrientation(VERTICAL); - LineTheme.padding(list, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); + LineTheme.padding(list, 0, 12, 0, 12); for (TodoItem item : items) { LinearLayout row = buildRow(item); row.setMinimumHeight(minRowHeight); @@ -95,7 +103,7 @@ private LinearLayout buildRow(TodoItem item) { text.setPaintFlags(text.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); } text.setSingleLine(false); - text.setMaxLines(2); + text.setEllipsize(null); LayoutParams textParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); textParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallWriteView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallWriteView.java index 7a0f8bb4..cc14c0b1 100644 --- a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallWriteView.java +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolCallWriteView.java @@ -1,318 +1,183 @@ package cn.lineai.tool.ui; -import cn.lineai.tool.ToolCallCardView; -import cn.lineai.tool.ToolReviewListener; -import cn.lineai.model.tool.ToolCall; -import cn.lineai.model.tool.ToolResult; -import cn.lineai.ui.theme.IconButtonView; -import cn.lineai.ui.theme.LineTheme; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.graphics.Typeface; +import android.os.Handler; +import android.os.Looper; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; import android.view.Gravity; -import android.view.View; -import android.widget.HorizontalScrollView; import android.widget.LinearLayout; import android.widget.TextView; -import cn.lineai.tool.ui.R; import cn.lineai.model.DiffUiModel; -import java.util.Locale; -import org.json.JSONObject; - -public final class ToolCallWriteView extends BaseToolCallView implements ToolCallCardView { - private ToolReviewListener toolReviewListener; +import cn.lineai.model.tool.ToolCall; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.tool.ToolCallCardView; +import cn.lineai.tool.ToolReviewListener; +import cn.lineai.ui.theme.BoundedScrollView; +import cn.lineai.ui.theme.IconButtonView; +import cn.lineai.ui.theme.LineTheme; +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +public final class ToolCallWriteView extends BaseToolCallView implements ToolCallCardView, ToolCallExpansion { + private static final ExecutorService DIFF_WORKER = Executors.newFixedThreadPool(2); + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private final TextView label; + private final IconButtonView arrow; + private final LinearLayout detail; + private final TextView fileName; + private final LinearLayout actions; + private final TextView errorText; + private final DiffView diffView; + private ToolReviewListener reviewer; + private DiffLoader loader; + private ToolCall call; + private ToolResult result; + private DiffUiModel record; + private DiffLines diff; + private Future task; + private boolean loadFailed; + private int version; + private String requestedId = ""; private String projectPath = ""; - private DiffLoader diffLoader; - private boolean diffExpanded; + private Map expansion = new HashMap<>(); + private String key = "write"; public ToolCallWriteView(Context context) { - super(context); - } - - public void setToolReviewListener(ToolReviewListener listener) { - toolReviewListener = listener; - } - - public void setProjectPath(String projectPath) { - this.projectPath = projectPath == null ? "" : projectPath; - } - - public void setDiffLoader(DiffLoader diffLoader) { - this.diffLoader = diffLoader; - } - - public void bind(ToolCall toolCall, ToolResult result) { - removeAllViews(); - setTag(new Object[] {toolCall, result}); - DiffUiModel diffRecord = loadDiff(result); - - LinearLayout body = new LinearLayout(getContext()); - body.setOrientation(VERTICAL); - LineTheme.padding(body, LineTheme.SM, LineTheme.XS, LineTheme.SM, LineTheme.XS); - - body.addView(buildHeader(toolCall, result, diffRecord), - new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - boolean complete = result != null; - boolean error = result != null && result.isError(); - boolean hasDiff = complete && !error && diffRecord != null; - String reviewState = result == null ? "" : result.getReviewState(); - boolean rejected = "rejected".equals(reviewState) || (diffRecord != null && diffRecord.isReverted()); - boolean accepted = "accepted".equals(reviewState); - // 动作行只在等待审查时出现,完成/失败态不再占用一行 - if (hasDiff && !accepted && !rejected) { - LayoutParams actionRowParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - actionRowParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); - body.addView(buildReviewRow(toolCall, result), actionRowParams); - } - addView(body, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - buildDiffSection(toolCall, result, diffRecord); - buildMessage(toolCall, result, diffRecord); - } - - private LinearLayout buildHeader(ToolCall toolCall, ToolResult result, DiffUiModel diffRecord) { - JSONObject input = ToolCallUtils.parseInput(toolCall); - String filePath = input.optString("file_path"); - String fileName = fileName(filePath); - String displayName = fileName.length() == 0 ? getContext().getString(R.string.tool_call_write_unnamed) : fileName; - boolean complete = result != null; - boolean error = result != null && result.isError(); - String reviewState = result == null ? "" : result.getReviewState(); - boolean rejected = "rejected".equals(reviewState) || (diffRecord != null && diffRecord.isReverted()); - int statusColor = error || rejected ? LineTheme.DANGER : complete ? LineTheme.SUCCESS : LineTheme.ACCENT; - String targetPath = diffRecord != null && diffRecord.getFilePath().length() > 0 ? diffRecord.getFilePath() : filePath; - if (fileName.length() == 0 && targetPath.length() > 0) { - fileName = fileName(targetPath); - displayName = fileName.length() == 0 ? getContext().getString(R.string.tool_call_write_unnamed) : fileName; - } - String shownPath = ToolCallUtils.workspaceDisplayPath(projectPath, targetPath); - if (shownPath.length() == 0) { - shownPath = displayName; - } - - LinearLayout fileRow = new LinearLayout(getContext()); - fileRow.setOrientation(HORIZONTAL); - fileRow.setGravity(Gravity.CENTER_VERTICAL); - - IconButtonView fileIcon = new IconButtonView(getContext(), IconButtonView.FILE_CODE); - fileIcon.setIconColor(statusColor); - fileIcon.setIconSizeDp(24, 12); - fileIcon.setClickable(false); - fileRow.addView(fileIcon, new LayoutParams(LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); - - LinearLayout meta = new LinearLayout(getContext()); - meta.setOrientation(VERTICAL); - LayoutParams metaParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - metaParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - metaParams.rightMargin = LineTheme.dp(getContext(), LineTheme.SM); - fileRow.addView(meta, metaParams); - - LinearLayout titleRow = new LinearLayout(getContext()); - titleRow.setOrientation(HORIZONTAL); - titleRow.setGravity(Gravity.CENTER_VERTICAL); - TextView action = LineTheme.text(getContext(), actionLabel(toolCall, result, input), 10, - statusColor == LineTheme.DANGER ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY, Typeface.BOLD); - titleRow.addView(action, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - TextView title = LineTheme.text(getContext(), displayName, LineTheme.FONT_SM, LineTheme.TEXT, Typeface.NORMAL); - title.setSingleLine(true); - LayoutParams titleParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); - titleParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - titleRow.addView(title, titleParams); - meta.addView(titleRow, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - TextView path = LineTheme.text(getContext(), shownPath, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - path.setSingleLine(true); - path.setHorizontallyScrolling(true); - HorizontalScrollView pathScroll = horizontalPathScroll(path); - meta.addView(pathScroll, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - View status = statusView(!complete); - if (status instanceof IconButtonView) { - IconButtonView statusIcon = (IconButtonView) status; - statusIcon.setIconSizeDp(24, 12); - statusIcon.setIconColor(statusColor); - if (error || rejected) { - statusIcon.setIconType(IconButtonView.CLOSE); - } - } - fileRow.addView(status, new LayoutParams(LineTheme.dp(getContext(), 24), LineTheme.dp(getContext(), 24))); - return fileRow; - } - - private LinearLayout buildReviewRow(ToolCall toolCall, ToolResult result) { - JSONObject input = ToolCallUtils.parseInput(toolCall); - - LinearLayout actionRow = new LinearLayout(getContext()); - actionRow.setOrientation(HORIZONTAL); - actionRow.setGravity(Gravity.CENTER_VERTICAL); - actionRow.setMinimumHeight(LineTheme.dp(getContext(), 28)); - LayoutParams actionParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - TextView actionBadge = LineTheme.text(getContext(), actionLabel(toolCall, result, input), - LineTheme.FONT_XS, LineTheme.ACCENT, Typeface.BOLD); - actionBadge.setGravity(Gravity.CENTER); - actionBadge.setMinHeight(LineTheme.dp(getContext(), 24)); - actionBadge.setBackground(LineTheme.roundedStroke(getContext(), LineTheme.ACCENT_MUTED, 4, LineTheme.ACCENT_MUTED_2)); - LineTheme.padding(actionBadge, LineTheme.SM, 2, LineTheme.SM, 2); - actionRow.addView(actionBadge, actionParams); - - View spacer = new View(getContext()); - actionRow.addView(spacer, new LayoutParams(0, 1, 1f)); - LinearLayout rejectButton = reviewButton(IconButtonView.CLOSE, getContext().getString(R.string.tool_call_write_revert), LineTheme.DANGER, LineTheme.SURFACE_LIGHT, LineTheme.DANGER_MUTED_2); - rejectButton.setOnClickListener(v -> { - if (toolReviewListener != null) { - toolReviewListener.onToolReview(toolCall == null ? "" : toolCall.getId(), "rejected", result.getDiffId()); - } - }); - actionRow.addView(rejectButton, new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(getContext(), 30))); - - LinearLayout acceptButton = reviewButton(IconButtonView.CHECK, getContext().getString(R.string.tool_call_write_accept), LineTheme.TEXT_ON_COLOR, LineTheme.ACCENT, LineTheme.ACCENT); - acceptButton.setOnClickListener(v -> { - if (toolReviewListener != null) { - toolReviewListener.onToolReview(toolCall == null ? "" : toolCall.getId(), "accepted", result.getDiffId()); - } - }); - LayoutParams acceptParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LineTheme.dp(getContext(), 30)); - acceptParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - actionRow.addView(acceptButton, acceptParams); - return actionRow; - } - - private void buildDiffSection(ToolCall toolCall, ToolResult result, DiffUiModel diffRecord) { - boolean complete = result != null; - boolean error = result != null && result.isError(); - boolean hasDiff = complete && !error && diffRecord != null; - if (hasDiff) { - addDiffSection(diffRecord); - } - } - - private void buildMessage(ToolCall toolCall, ToolResult result, DiffUiModel diffRecord) { - boolean complete = result != null; - boolean error = result != null && result.isError(); - boolean hasDiff = complete && !error && diffRecord != null; - if (error && result.getContent().length() > 0) { - addMessage(result.getContent(), LineTheme.DANGER); - } else if (result != null && result.getReviewMessage().length() > 0 && result.getReviewState().length() == 0) { - addMessage(result.getReviewMessage(), LineTheme.DANGER); - } else if (complete && !hasDiff && result.getContent().length() > 0) { - addMessage(result.getContent(), LineTheme.TEXT_SECONDARY); - } - } - - private DiffUiModel loadDiff(ToolResult result) { - if (result == null || result.getDiffId().length() == 0) { - return null; - } - if (diffLoader != null) { - try { - return diffLoader.loadDiff(result.getDiffId()); - } catch (Exception ignored) { - return null; - } - } - return null; - } - - private LinearLayout reviewButton(int iconType, String label, int color, int background, int border) { - LinearLayout button = new LinearLayout(getContext()); - button.setOrientation(HORIZONTAL); - button.setGravity(Gravity.CENTER); - button.setBackground(LineTheme.roundedStroke(getContext(), background, 6, border)); - button.setClickable(true); - button.setMinimumHeight(LineTheme.dp(getContext(), 30)); - LineTheme.padding(button, LineTheme.SM, 2, LineTheme.SM, 2); - - IconButtonView icon = new IconButtonView(getContext(), iconType); - icon.setIconColor(color); - icon.setIconSizeDp(18, 12); - icon.setClickable(false); - button.addView(icon, new LayoutParams(LineTheme.dp(getContext(), 18), LineTheme.dp(getContext(), 18))); - - TextView text = LineTheme.text(getContext(), label, LineTheme.FONT_XS, color, Typeface.BOLD); - LayoutParams textParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - textParams.leftMargin = LineTheme.dp(getContext(), 2); - button.addView(text, textParams); - return button; - } - - private void addDiffSection(DiffUiModel record) { - View divider = new View(getContext()); - divider.setBackgroundColor(LineTheme.CODE_BORDER); - addView(divider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - - LinearLayout header = new LinearLayout(getContext()); - header.setOrientation(HORIZONTAL); - header.setGravity(Gravity.CENTER_VERTICAL); - header.setClickable(true); - LineTheme.padding(header, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - - IconButtonView arrow = new IconButtonView(getContext(), diffExpanded ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); - arrow.setIconColor(LineTheme.ACCENT); - arrow.setIconSizeDp(16, 12); - arrow.setClickable(false); - header.addView(arrow, new LayoutParams(LineTheme.dp(getContext(), 16), LineTheme.dp(getContext(), 16))); - - TextView label = LineTheme.text(getContext(), getContext().getString(R.string.tool_call_view_diff), LineTheme.FONT_XS, LineTheme.ACCENT, Typeface.BOLD); - LayoutParams labelParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - labelParams.leftMargin = LineTheme.dp(getContext(), LineTheme.XS); + super(context); setBackground(null); + LinearLayout header = row(); header.setMinimumHeight(dp(48)); + IconButtonView icon = new IconButtonView(context, IconButtonView.FILE_PEN_LINE); + icon.setIconSizeDp(24, 16); icon.setIconColor(LineTheme.TEXT_SECONDARY); icon.setClickable(false); + icon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + header.addView(icon, new LayoutParams(dp(24), dp(32))); + label = LineTheme.text(context, "", 14, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + label.setSingleLine(true); label.setEllipsize(TextUtils.TruncateAt.MIDDLE); + LayoutParams labelParams = new LayoutParams(0, -2, 1); labelParams.leftMargin = dp(6); header.addView(label, labelParams); - header.setOnClickListener(v -> { - diffExpanded = !diffExpanded; - bindLast(); + arrow = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); arrow.setIconSizeDp(24, 14); + arrow.setIconColor(LineTheme.TEXT_SECONDARY); arrow.setClickable(false); + arrow.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + header.addView(arrow, new LayoutParams(dp(24), dp(32))); + header.setFocusable(true); header.setOnClickListener(v -> { expansion.put(key, !isExpanded()); render(); }); + addView(header, new LayoutParams(-1, -2)); + detail = new LinearLayout(context); detail.setOrientation(VERTICAL); + detail.setBackground(LineTheme.roundedStroke(context, LineTheme.CODE_BG, 12, LineTheme.CODE_BORDER)); + detail.setClipToOutline(true); + LinearLayout fileHeader = row(); LineTheme.padding(fileHeader, 12, 0, 0, 0); + fileName = LineTheme.text(context, "", 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + fileName.setSingleLine(true); fileName.setEllipsize(TextUtils.TruncateAt.MIDDLE); + fileHeader.addView(fileName, new LayoutParams(0, -2, 1)); + IconButtonView copy = new IconButtonView(context, IconButtonView.COPY); copy.setIconSizeDp(44, 16); + copy.setIconColor(LineTheme.TEXT_SECONDARY); copy.setContentDescription(context.getString(R.string.tool_call_copy_file)); + copy.setOnClickListener(v -> { + if (record == null) return; + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null) clipboard.setPrimaryClip(ClipData.newPlainText(record.getFilePath(), record.getNewContent())); }); - addView(header, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - - if (diffExpanded) { - DiffView diffView = new DiffView(getContext()); - diffView.bind(record.getOldContent(), record.getNewContent()); - addView(diffView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + fileHeader.addView(copy, new LayoutParams(dp(44), dp(44))); + detail.addView(fileHeader, new LayoutParams(-1, -2)); + BoundedScrollView scroll = new BoundedScrollView(context, 224); + diffView = new DiffView(context); scroll.addView(diffView, new android.widget.ScrollView.LayoutParams(-1, -2)); + detail.addView(scroll, new LayoutParams(-1, -2)); + errorText = LineTheme.text(context, "", 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); + LineTheme.padding(errorText, 14, 10, 14, 10); detail.addView(errorText, new LayoutParams(-1, -2)); + actions = row(); actions.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); LineTheme.padding(actions, 8, 6, 8, 6); + TextView revert = button(context.getString(R.string.tool_call_write_revert)); + revert.setOnClickListener(v -> review("rejected")); actions.addView(revert); + TextView accept = button(context.getString(R.string.tool_call_write_accept)); + accept.setOnClickListener(v -> review("accepted")); actions.addView(accept); + detail.addView(actions, new LayoutParams(-1, -2)); + addView(detail, new LayoutParams(-1, -2)); render(); + } + @Override public void bind(ToolCall call, ToolResult result) { + boolean different = this.call == null || call == null || !this.call.getId().equals(call.getId()); + this.call = call; this.result = result; + String id = result == null ? "" : result.getDiffId(); + if (different || !requestedId.equals(id)) { + cancelLoad(); record = null; diff = null; loadFailed = false; requestedId = id; } + render(); requestDiff(); + } + public void setDiffLoader(DiffLoader loader) { this.loader = loader; requestDiff(); } + @Override public void setToolReviewListener(ToolReviewListener reviewer) { this.reviewer = reviewer; } + @Override public void setProjectPath(String path) { projectPath = path == null ? "" : path; } + @Override public void setExpansionState(Map state, String key) { + if (state != null) expansion = state; + this.key = key; render(); + } + private void requestDiff() { + if (!isAttachedToWindow() || loader == null || requestedId.isEmpty() || record != null || task != null || loadFailed) return; + final String id = requestedId; + final int expected = ++version; + final DiffLoader source = loader; + final WeakReference reference = new WeakReference<>(this); + task = DIFF_WORKER.submit(() -> { + DiffUiModel loaded = null; + try { loaded = source.loadDiff(id); } catch (Exception ignored) { /* Show an unavailable state, keep the operation reviewable. */ } + final DiffUiModel value = loaded; + final DiffLines lines = value == null ? null : DiffLines.calculate(value.getOldContent(), value.getNewContent()); + MAIN.post(() -> { + ToolCallWriteView target = reference.get(); + if (target == null || target.version != expected || !target.isAttachedToWindow()) return; + target.task = null; target.record = value; target.diff = lines; target.loadFailed = value == null; + if (lines != null) target.diffView.bind(lines); + target.render(); + }); + }); } - - private void bindLast() { - if (getTag() instanceof Object[]) { - Object[] values = (Object[]) getTag(); - bind((ToolCall) values[0], (ToolResult) values[1]); - } - } - - private void addMessage(String text, int color) { - View divider = new View(getContext()); - divider.setBackgroundColor(LineTheme.CODE_BORDER); - addView(divider, new LayoutParams(LayoutParams.MATCH_PARENT, 1)); - TextView result = LineTheme.text(getContext(), text, LineTheme.FONT_XS, color, Typeface.NORMAL); - LineTheme.padding(result, LineTheme.MD, LineTheme.SM, LineTheme.MD, LineTheme.SM); - addView(result, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); - } - - private String fileName(String path) { - if (path == null || path.length() == 0) { - return ""; - } - int index = path.lastIndexOf('/'); - return index >= 0 ? path.substring(index + 1) : path; - } - - private String actionLabel(ToolCall toolCall, ToolResult result, JSONObject input) { - String name = toolCall == null ? "" : toolCall.getName(); - String resultName = result == null ? "" : result.getToolName(); - if (isEditName(name) || isEditName(resultName) || hasEditShape(input)) { - return getContext().getString(R.string.common_edit); + private void render() { + if (label == null || detail == null) return; + String path = record != null ? record.getFilePath() : ToolCallUtils.parseInput(call).optString("file_path", + ToolCallUtils.parseInput(call).optString("path", "")); + String name = path.isEmpty() ? getContext().getString(R.string.tool_call_write_unnamed) : path.substring(path.lastIndexOf('/') + 1); + boolean failed = result != null && result.isError(); + int status = result == null || "running".equals(result.getReviewState()) ? R.string.tool_call_status_running + : failed ? R.string.tool_call_status_failed : "pending".equals(result.getReviewState()) ? R.string.tool_call_status_pending_review + : "rejected".equals(result.getReviewState()) ? R.string.tool_call_write_reverted : R.string.tool_call_write_done; + if (status == R.string.tool_call_write_done && record != null && record.getOldContent().isEmpty() + && call != null && cn.lineai.tool.ToolNames.FILE_WRITE.equals(call.getName())) status = R.string.tool_call_write_created; + label.setText(counts(getContext().getString(status) + " " + name)); + label.setTextColor(failed ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); + label.setContentDescription(getContext().getString(status) + " " + ToolCallUtils.workspaceDisplayPath(projectPath, path)); + if (fileName != null) fileName.setText(counts(name)); + boolean expanded = isExpanded(); detail.setVisibility(expanded ? VISIBLE : GONE); + arrow.setIconType(expanded ? IconButtonView.CHEVRON_DOWN : IconButtonView.CHEVRON_RIGHT); + if (diffView != null) diffView.setVisibility(diff == null ? GONE : VISIBLE); + if (actions != null) actions.setVisibility(result != null && !result.getDiffId().isEmpty() + && !"accepted".equals(result.getReviewState()) && !"rejected".equals(result.getReviewState()) ? VISIBLE : GONE); + if (errorText != null) { + String message = failed ? result.getContent() : result != null && !result.getReviewMessage().isEmpty() ? result.getReviewMessage() + : record == null ? getContext().getString(requestedId.isEmpty() || loadFailed ? R.string.tool_call_diff_unavailable : R.string.tool_call_diff_loading) : ""; + errorText.setText(message); errorText.setTextColor(failed ? LineTheme.DANGER : LineTheme.TEXT_SECONDARY); + errorText.setVisibility(message.isEmpty() ? GONE : VISIBLE); } - return getContext().getString(R.string.tool_call_action_write); - } - - private boolean isEditName(String name) { - String compact = name == null ? "" : name.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); - return "fileedit".equals(compact) || "editfile".equals(compact); - } - - private boolean hasEditShape(JSONObject input) { - return input != null && (input.has("old_string") - || input.has("new_string") - || input.has("search") - || input.has("replace") - || input.has("patch") - || input.has("edits")); } + private CharSequence counts(String text) { + if (diff == null) return text; + SpannableStringBuilder result = new SpannableStringBuilder(text + " "); + int start = result.length(); result.append("+" + diff.added); + result.setSpan(new ForegroundColorSpan(LineTheme.SUCCESS), start, result.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + result.append(" "); start = result.length(); result.append("−" + diff.removed); + result.setSpan(new ForegroundColorSpan(LineTheme.DANGER), start, result.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); return result; + } + private void review(String state) { if (reviewer != null && call != null && result != null) reviewer.onToolReview(call.getId(), state, result.getDiffId()); } + private boolean isExpanded() { return Boolean.TRUE.equals(expansion.get(key)); } + private int dp(int value) { return LineTheme.dp(getContext(), value); } + private LinearLayout row() { LinearLayout row = new LinearLayout(getContext()); row.setGravity(Gravity.CENTER_VERTICAL); return row; } + private TextView button(String text) { + TextView button = LineTheme.text(getContext(), text, 13, LineTheme.TEXT, Typeface.NORMAL); + button.setGravity(Gravity.CENTER); button.setMinHeight(dp(48)); LineTheme.padding(button, 14, 0, 14, 0); button.setFocusable(true); return button; + } + private void cancelLoad() { version++; if (task != null) task.cancel(true); task = null; } + @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); requestDiff(); } + @Override protected void onDetachedFromWindow() { cancelLoad(); super.onDetachedFromWindow(); } } diff --git a/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolErrorView.java b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolErrorView.java new file mode 100644 index 00000000..12a78d0f --- /dev/null +++ b/tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolErrorView.java @@ -0,0 +1,37 @@ +package cn.lineai.tool.ui; + +import android.content.Context; +import android.graphics.Typeface; +import android.widget.LinearLayout; +import android.widget.TextView; +import cn.lineai.model.tool.ToolResult; +import cn.lineai.ui.theme.BoundedScrollView; +import cn.lineai.ui.theme.LineTheme; + +/** Error output embedded in a tool that otherwise has no result body. */ +public final class ToolErrorView extends LinearLayout { + private final TextView details; + + public ToolErrorView(Context context) { + super(context); + setOrientation(VERTICAL); + setBackground(LineTheme.roundedStroke(context, LineTheme.CODE_BG, 12, LineTheme.CODE_BORDER)); + BoundedScrollView scroll = new BoundedScrollView(context, 240); + details = LineTheme.text(context, "", 13, LineTheme.DANGER, Typeface.NORMAL); + LineTheme.padding(details, 14, 12, 14, 12); + details.setLineSpacing(LineTheme.dp(context, 6), 1); + details.setTextIsSelectable(true); + scroll.addView(details, new android.widget.ScrollView.LayoutParams(-1, -2)); + addView(scroll, new LayoutParams(-1, -2)); + setVisibility(GONE); + } + + public void bind(ToolResult result) { + if (result == null || !result.isError()) { setVisibility(GONE); return; } + setVisibility(VISIBLE); + String output = AgentToolResultDisplay.progressPayload(result.getContent()) == null + ? result.getContent() : AgentToolResultDisplay.displayOutput(result.getContent()); + if (output.trim().isEmpty()) output = getContext().getString(R.string.tool_call_status_failed); + details.setText(output); + } +} diff --git a/tool-ui/src/main/res/values-ru/strings.xml b/tool-ui/src/main/res/values-ru/strings.xml index d1eaa1c7..cf48e2e5 100644 --- a/tool-ui/src/main/res/values-ru/strings.xml +++ b/tool-ui/src/main/res/values-ru/strings.xml @@ -1,5 +1,6 @@ + Подробности ошибки %1$d строк Отмена Удалить @@ -57,4 +58,12 @@ MCP вызов MCP вызов Действие с телефоном + Прочитано + Скопировать содержимое файла + Изменено + Отменено + Загрузка изменений… + Изменения недоступны + Создано + Нет перевода строки в конце файла diff --git a/tool-ui/src/main/res/values-zh/strings.xml b/tool-ui/src/main/res/values-zh/strings.xml index b0788fb0..51a056d1 100644 --- a/tool-ui/src/main/res/values-zh/strings.xml +++ b/tool-ui/src/main/res/values-zh/strings.xml @@ -1,5 +1,6 @@ + 错误详情 %1$d 行 取消 删除 @@ -57,4 +58,12 @@ MCP 调用 MCP 调用 手机操作 + 已读取 + 复制文件内容 + 已编辑 + 已撤销 + 正在加载差异… + 暂无可用差异 + 已创建 + 文件末尾没有换行符 diff --git a/tool-ui/src/main/res/values/strings.xml b/tool-ui/src/main/res/values/strings.xml index 6e28ad70..dddff708 100644 --- a/tool-ui/src/main/res/values/strings.xml +++ b/tool-ui/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + Error details %1$d lines Cancel Delete @@ -57,4 +58,12 @@ MCP call MCP call Phone action + Read + Copy file contents + Edited + Reverted + Loading changes… + No diff available + Created + No newline at end of file diff --git a/tool-ui/src/test/java/cn/lineai/tool/ui/DiffLinesTest.java b/tool-ui/src/test/java/cn/lineai/tool/ui/DiffLinesTest.java new file mode 100644 index 00000000..f9d8def5 --- /dev/null +++ b/tool-ui/src/test/java/cn/lineai/tool/ui/DiffLinesTest.java @@ -0,0 +1,47 @@ +package cn.lineai.tool.ui; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class DiffLinesTest { + @Test public void creationDoesNotInventAnEmptyRemovedLine() { + DiffLines diff = DiffLines.calculate("", "#!/bin/sh\necho ready\n"); + assertEquals(2, diff.added); assertEquals(0, diff.removed); + assertEquals(1, diff.lines.get(0).number); assertEquals(2, diff.lines.get(1).number); + } + @Test public void replacementKeepsContextAndAccurateLineNumbers() { + DiffLines diff = DiffLines.calculate("one\nold\nthree\n", "one\nnew\nextra\nthree\n"); + assertEquals(2, diff.added); assertEquals(1, diff.removed); + assertEquals(-1, diff.lines.get(1).kind); assertEquals(2, diff.lines.get(1).number); + assertEquals(1, diff.lines.get(2).kind); assertEquals(2, diff.lines.get(2).number); + assertEquals(4, diff.lines.get(4).number); + } + @Test public void blankLinesAndDeletionRemainRealLines() { + DiffLines blank = DiffLines.calculate("", "\n"); assertEquals(1, blank.added); + DiffLines deleted = DiffLines.calculate("a\n\nb\n", ""); assertEquals(3, deleted.removed); assertEquals(0, deleted.added); + } + @Test public void unchangedFilesHaveNoChanges() { + DiffLines diff = DiffLines.calculate("a\r\nb\r\n", "a\nb\n"); + assertEquals(0, diff.added); assertEquals(0, diff.removed); + } + @Test(timeout = 3000) public void largeUnrelatedFilesHaveBoundedMemoryAndReconstructCorrectly() { + StringBuilder a = new StringBuilder(), b = new StringBuilder(); + for (int i = 0; i < 8000; i++) { a.append("old ").append(i).append('\n'); b.append("new ").append(i).append('\n'); } + DiffLines diff = DiffLines.calculate(a.toString(), b.toString()); + assertEquals(8000, diff.added); assertEquals(8000, diff.removed); + StringBuilder result = new StringBuilder(); + for (DiffLines.Line line : diff.lines) if (line.kind >= 0) result.append(line.text).append('\n'); + assertEquals(b.toString(), result.toString()); + } + @Test public void addingATerminatingNewlineIsARealChange() { + DiffLines diff = DiffLines.calculate("same", "same\n"); + assertEquals(1, diff.added); assertEquals(1, diff.removed); + assertFalse(diff.lines.get(0).terminated); assertTrue(diff.lines.get(1).terminated); + } + @Test public void anEditNearTheEndKeepsItsSourceLineNumber() { + StringBuilder a = new StringBuilder(); for (int i = 0; i < 1000; i++) a.append("same\n"); + DiffLines diff = DiffLines.calculate(a + "old\n", a + "new\n"); + assertEquals(1, diff.added); assertEquals(1, diff.removed); + assertEquals(1001, diff.lines.get(diff.lines.size() - 1).number); + } +} diff --git a/ui-theme/src/main/java/cn/lineai/ui/theme/IconButtonView.java b/ui-theme/src/main/java/cn/lineai/ui/theme/IconButtonView.java index c4139ffe..93fa1f19 100644 --- a/ui-theme/src/main/java/cn/lineai/ui/theme/IconButtonView.java +++ b/ui-theme/src/main/java/cn/lineai/ui/theme/IconButtonView.java @@ -184,6 +184,12 @@ public final class IconButtonView extends ImageButton { private int iconType; private int iconColor = Color.WHITE; + @Override public void setClickable(boolean clickable) { + super.setClickable(clickable); + setFocusable(clickable); + setImportantForAccessibility(clickable ? IMPORTANT_FOR_ACCESSIBILITY_AUTO : IMPORTANT_FOR_ACCESSIBILITY_NO); + } + public IconButtonView(Context context, int iconType) { super(context); setScaleType(ScaleType.FIT_CENTER); @@ -217,8 +223,26 @@ public void setIconPaddingDp(int left, int top, int right, int bottom) { } public void setIconSizeDp(int containerDp, int iconDp) { + requestedIconDp = iconDp; int padding = Math.max(0, Math.round((containerDp - iconDp) / 2f)); setIconPaddingDp(padding, padding, padding, padding); + updateIconPadding(getWidth(), getHeight()); + } + + private int requestedIconDp; + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + updateIconPadding(width, height); + } + + private void updateIconPadding(int width, int height) { + if (requestedIconDp <= 0 || width <= 0 || height <= 0) return; + int size = Math.min(Math.min(width, height), LineTheme.dp(getContext(), requestedIconDp)); + int horizontal = (width - size) / 2; + int vertical = (height - size) / 2; + setPadding(horizontal, vertical, width - size - horizontal, height - size - vertical); } public int getIconType() { diff --git a/ui-theme/src/main/java/cn/lineai/ui/theme/LineTheme.java b/ui-theme/src/main/java/cn/lineai/ui/theme/LineTheme.java index 6ed8cc88..6ca2d716 100644 --- a/ui-theme/src/main/java/cn/lineai/ui/theme/LineTheme.java +++ b/ui-theme/src/main/java/cn/lineai/ui/theme/LineTheme.java @@ -50,11 +50,13 @@ public final class LineTheme { public static final int FONT_XS = 11; public static final int FONT_SM = 13; - public static final int FONT_MD = 15; + public static final int FONT_MD = 16; public static final int FONT_LG = 17; public static final int FONT_XL = 20; - public static final int FONT_TITLE = 24; - public static final int FONT_XXL = 28; + public static final int FONT_TITLE = 22; + public static final int FONT_XXL = 26; + + static { apply(ThemePalette.forMode("dark")); } private LineTheme() { } @@ -125,17 +127,27 @@ public static GradientDrawable roundedTop(Context context, int color, float radi } public static GradientDrawable userBubble(Context context) { - GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(USER_BUBBLE); - float large = dp(context, 16); - float small = dp(context, 4); - drawable.setCornerRadii(new float[] { - large, large, - large, large, - small, small, - large, large - }); - return drawable; + return rounded(context, USER_BUBBLE, 18); + } + + public static android.graphics.drawable.Drawable pressable(Context context) { + android.graphics.drawable.StateListDrawable states = new android.graphics.drawable.StateListDrawable(); + states.addState(new int[] { android.R.attr.state_pressed }, rounded(context, ACCENT_MUTED_2, 12)); + states.addState(new int[] {}, roundedStroke(context, SURFACE_ELEVATED, 12, BORDER_LIGHT)); + return states; + } + + public static android.graphics.drawable.Drawable fieldBackground(Context context) { + android.graphics.drawable.StateListDrawable states = new android.graphics.drawable.StateListDrawable(); + states.addState(new int[] { android.R.attr.state_focused }, roundedStroke(context, INPUT_BG, 12, TEXT_SECONDARY)); + states.addState(new int[] {}, rounded(context, INPUT_BG, 12)); + return states; + } + + public static int textOn(int background) { + double luminance = (0.2126 * Color.red(background) + 0.7152 * Color.green(background) + + 0.0722 * Color.blue(background)) / 255.0; + return luminance > 0.55 ? Color.rgb(36, 38, 42) : Color.rgb(237, 240, 242); } public static void padding(View view, int left, int top, int right, int bottom) { diff --git a/ui-theme/src/main/java/cn/lineai/ui/theme/ThinkingBlockView.java b/ui-theme/src/main/java/cn/lineai/ui/theme/ThinkingBlockView.java index 2441ec91..fc16055c 100644 --- a/ui-theme/src/main/java/cn/lineai/ui/theme/ThinkingBlockView.java +++ b/ui-theme/src/main/java/cn/lineai/ui/theme/ThinkingBlockView.java @@ -41,23 +41,19 @@ public ThinkingBlockView(Context context) { header.setOrientation(HORIZONTAL); header.setGravity(Gravity.CENTER_VERTICAL); header.setClickable(true); - LineTheme.padding(header, 0, 4, 0, 4); + LineTheme.padding(header, 0, 8, 0, 8); + header.setMinimumHeight(LineTheme.dp(context,48)); this.header = header; - TextView mark = LineTheme.text(context, "✦", 10, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); - header.addView(mark, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - - labelView = LineTheme.text(context, context.getString(R.string.thinking_label), LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + labelView = LineTheme.text(context, context.getString(R.string.thinking_label), 13, LineTheme.TEXT_SECONDARY, Typeface.NORMAL); LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - labelParams.leftMargin = LineTheme.dp(context, 4); header.addView(labelView, labelParams); chevronView = new IconButtonView(context, IconButtonView.CHEVRON_RIGHT); chevronView.setIconColor(LineTheme.TEXT_TERTIARY); - chevronView.setIconSizeDp(12, 12); + chevronView.setIconSizeDp(28, 16); chevronView.setClickable(false); - LinearLayout.LayoutParams chevronParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 12), LineTheme.dp(context, 12)); - chevronParams.leftMargin = LineTheme.dp(context, 4); + LinearLayout.LayoutParams chevronParams = new LinearLayout.LayoutParams(LineTheme.dp(context, 28), LineTheme.dp(context, 32)); header.addView(chevronView, chevronParams); header.setOnClickListener(v -> { expanded = !expanded; @@ -71,7 +67,7 @@ public ThinkingBlockView(Context context) { contentScrollView.setOverScrollMode(OVER_SCROLL_IF_CONTENT_SCROLLS); contentScrollView.setVerticalScrollBarEnabled(true); - contentView = LineTheme.text(context, "", LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + contentView = LineTheme.text(context, "", LineTheme.FONT_SM, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); contentView.setLineSpacing(LineTheme.dp(context, 4), 1f); contentScrollView.addView(contentView, new ScrollView.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); @@ -102,19 +98,8 @@ private void setStreaming(boolean streaming) { return; } this.streaming = streaming; - if (streaming) { - pulseAnimator = ObjectAnimator.ofFloat(header, View.ALPHA, 1f, STREAMING_PULSE_MIN_ALPHA); - pulseAnimator.setDuration(STREAMING_PULSE_MS); - pulseAnimator.setRepeatCount(ValueAnimator.INFINITE); - pulseAnimator.setRepeatMode(ValueAnimator.REVERSE); - pulseAnimator.start(); - } else { - if (pulseAnimator != null) { - pulseAnimator.cancel(); - pulseAnimator = null; - } - header.setAlpha(1f); - } + if (pulseAnimator != null) { pulseAnimator.cancel(); pulseAnimator = null; } + header.setAlpha(1f); } @Override diff --git a/update.md b/update.md index c5a873b1..a20939ae 100644 --- a/update.md +++ b/update.md @@ -1,5 +1,110 @@ # 更新日志 +## v1.2.8-max + +### 原生 UI 与聊天布局重写 + +- **原生界面重构** - 重新整理聊天页、设置页、抽屉、底部弹窗和工具卡片的布局,统一页面底色、卡片层级、圆角、字体与图标比例;继续使用 Android 原生 View,减少重复边框、过大的标题和占用空间过多的装饰组件 +- **聊天导航恢复与调整** - 左上角保留三条杠菜单,点击工作区标题打开工作区选择抽屉;恢复权限选择器与三点菜单,新对话使用加号图标,附件加号继续用于选择文件,设置与会话导航保留在抽屉中 +- **用户消息与输入框** - 用户消息改为右对齐气泡,区别于助手正文和过程说明;收紧输入框默认高度、内部留白与底部操作区,调整正文到屏幕两侧的距离 +- **统一页头操作尺寸** - `ScreenHeaderView` 统一返回及图标操作按钮的 48dp 点击区域与 22dp 图标尺寸,修复错误日志、添加模型等页面右上角按钮过大,以及保存操作贴到屏幕最右侧的问题 +- **自适应页面与弹窗** - 新增 `ScreenSurfaceView`、`InsetSheetLayout`、`AdaptiveActionsView` 等共享布局组件;宽屏限制内容宽度,底部弹窗限制最大高度,长内容可以滚动,窄屏操作按钮可以重新排列 +- **Markdown 排版调整** - 调整正文、标题、代码块的字号和间距,代码区域使用更紧凑的显示方式;保留链接、复制、代码换行等阅读操作 + +### 处理时间线、思考与最终输出 + +- **按回合组织处理过程** - 新增 `ConversationTimeline` 与 `AssistantTurnView`,将同一轮助手的过程文字、工具调用和结果组织到「处理中 / 已处理」区域,最终答复在折叠区域之外显示;普通无工具、无错误的文本答复不额外生成已处理区域 +- **连续工具调用分组折叠** - 中途没有正文输出的连续工具调用合并为一个工具组,默认收起,手动展开后查看各次调用;中间出现助手正文时结束当前分组,后续工具重新分组,展开状态在界面刷新时保留 +- **思考按发生顺序追加** - 每次思考作为独立组件进入对应位置,不再集中到顶部的单一思考区;连续工具执行期间的内部推理随工具过程收纳,不单独打断外层时间线,出现正文后再调用工具时继续按消息顺序展示思考 +- **工作状态与摘要兼容** - 整合主分支的 `WorkingStatusView`、OpenAI / Codex 思考摘要解析与思考文本强调样式;保留无正文时的工作状态,完成后隐藏状态行,同时维持新版消息操作栏默认隐藏、长按展开的交互 +- **Agent 独立展示** - 子 Agent 与 Agent 流水线继续使用各自的专用卡片,在处理时间线中直接展示,不并入需要再次展开的普通「使用了工具」汇总;保留 Agent 进度、内部输出和展开操作 +- **内部结束标记处理** - 系统工具规则加入 `` 作为处理结束标记;流式渲染隐藏从 `