From 2a3759868dfb7b9999fc7d61251c9cd3c2fdd337 Mon Sep 17 00:00:00 2001 From: andTDWF <3487854120@qq.com> Date: Tue, 11 Aug 2026 16:37:48 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20Skill=20Hub=20?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=95=86=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Skill Hub 登录、浏览、详情与发布流程,并扩展技能仓库和 HTTP 客户端以支持远程技能管理。 --- .../data/repository/ExtensionRepository.java | 5 + .../data/repository/SkillRepository.java | 27 + .../lineai/data/service/SkillFileManager.java | 37 +- .../lineai/data/service/SkillHubClient.java | 484 ++++++ .../data/service/SkillHubSessionClient.java | 486 ++++++ .../cn/lineai/mvp/ExtensionController.java | 2 + .../mvp/ExtensionManagementController.java | 5 + .../java/cn/lineai/mvp/MainCoordinator.java | 5 + .../main/java/cn/lineai/ui/MainChatView.java | 6 + .../component/ExtensionDetailScreenView.java | 14 + .../lineai/ui/component/ScreenFactories.java | 169 ++ .../component/SkillHubCenterScreenView.java | 104 ++ .../ui/component/SkillHubLoginScreenView.java | 189 +++ .../component/SkillHubPublishScreenView.java | 202 +++ .../ui/component/SkillHubWebScreenView.java | 169 ++ .../lineai/ui/component/SkillIconLoader.java | 64 + .../component/SkillStoreDetailScreenView.java | 1371 +++++++++++++++++ .../ui/component/SkillStoreScreenView.java | 644 ++++++++ .../data/service/SkillHubClientTest.java | 133 ++ .../service/SkillHubSessionClientTest.java | 90 ++ .../ExtensionManagementControllerTest.java | 92 ++ .../java/cn/lineai/model/SkillHubModels.java | 281 ++++ .../cn/lineai/security/SimpleHttpClient.java | 11 +- .../data/repository/ExtensionStore.java | 5 + .../cn/lineai/ui/markdown/MarkdownView.java | 92 ++ 25 files changed, 4680 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/cn/lineai/data/service/SkillHubClient.java create mode 100644 app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java create mode 100644 app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java create mode 100644 app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java create mode 100644 app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java create mode 100644 core-model/src/main/java/cn/lineai/model/SkillHubModels.java 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..da753f53 100644 --- a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java @@ -8,12 +8,14 @@ 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; import cn.lineai.model.SkillRecord; import cn.lineai.resource.ResourceProvider; import java.io.File; +import java.io.FileOutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -128,6 +130,31 @@ public synchronized SkillRecord installSkillFromGitHub(String homePath, String l } } + 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"); + tempDir.mkdirs(); + try { + byte[] bytes = new SkillHubClient().download(slug, version); + FileOutputStream output = new FileOutputStream(archive, false); + try { + output.write(bytes); + } finally { + output.close(); + } + return installSkill(homePath, location, archive.getAbsolutePath(), slug); + } finally { + fileManager.deleteRecursive(tempDir); + } + } + public synchronized void deleteSkills(List ids) { if (ids == null || ids.isEmpty()) { return; 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 25948145..21715c48 100644 --- a/app/src/main/java/cn/lineai/data/service/SkillFileManager.java +++ b/app/src/main/java/cn/lineai/data/service/SkillFileManager.java @@ -473,43 +473,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..6da2b81d --- /dev/null +++ b/app/src/main/java/cn/lineai/data/service/SkillHubClient.java @@ -0,0 +1,484 @@ +package cn.lineai.data.service; + +import cn.lineai.model.SkillHubModels; +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; + + 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("SkillHub 返回错误: " + root.optString("message")); + } + JSONObject data = root.optJSONObject("data"); + if (data == null) { + throw new IllegalArgumentException("SkillHub 列表响应缺少 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("SkillHub 详情响应缺少 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("SkillHub 文件内容过大"); + } + 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("SkillHub 下载内容不是有效 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("SkillHub 图标响应不是图片"); + } + return result.bytes; + } + + static 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("无效的 SkillHub 图标地址"); + } + return uri.toASCIIString(); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("无效的 SkillHub 图标地址", 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("SkillHub Skill 文档过大"); + } + 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("无效的评论 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( + "示例 " + (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("SkillHub 响应过大"); + } + try { + return new JSONObject(body); + } catch (Exception e) { + throw new IllegalArgumentException("SkillHub 返回了无效 JSON", e); + } + } + + static SkillHubModels.Summary parseSummary(JSONObject value) { + String slug = value.optString("slug").trim(); + if (!isSafeSlug(slug)) { + throw new IllegalArgumentException("SkillHub 返回了无效 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())); + } + } + + static String requireSlug(String value) { + String slug = value == null ? "" : value.trim(); + if (!isSafeSlug(slug)) { + throw new IllegalArgumentException("无效的 SkillHub slug"); + } + return slug; + } + + private static boolean isSafeSlug(String value) { + return value != null && value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + } + + static 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("无效的 SkillHub 文件路径"); + } + for (String segment : path.split("/", -1)) { + if (segment.length() == 0 || ".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException("无效的 SkillHub 文件路径"); + } + } + return path; + } + + private static 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("无效的 SkillHub 版本"); + } + 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..759e19d8 --- /dev/null +++ b/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java @@ -0,0 +1,486 @@ +package cn.lineai.data.service; + +import android.os.Build; +import android.webkit.CookieManager; +import cn.lineai.model.SkillHubModels; +import cn.lineai.model.SkillRecord; +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_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; + + 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("请选择 App 或当前项目中的本地 Skill"); + } + String slug = SkillHubClient.requireSlug(rawSlug); + String displayName = requireText(rawDisplayName, "Skill 名称", 100); + 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, "获取 SkillHub 账号失败"); + return Session.signedIn(parseAccount(new JSONObject(response.body))); + } + + public SkillHubModels.Comment postComment( + String rawSlug, String namespace, String rawContent) throws Exception { + String slug = SkillHubClient.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, "发表评论失败"); + return SkillHubClient.parseComment(new JSONObject(response.body)); + } + + public SkillHubModels.Comment postCommentReply( + String rawSlug, long commentId, String namespace, String rawContent) throws Exception { + String slug = SkillHubClient.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, "回复评论失败"); + return SkillHubClient.parseComment(new JSONObject(response.body)); + } + + public void setCommentLiked( + String rawSlug, long commentId, String namespace, boolean liked) throws Exception { + String slug = SkillHubClient.requireSlug(rawSlug); + requireCommentId(commentId); + SimpleHttpClient.Response response = request( + liked ? "POST" : "DELETE", + "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + + "/like" + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, liked ? "点赞评论失败" : "取消点赞失败"); + } + + public void deleteComment(String rawSlug, long commentId, String namespace) throws Exception { + String slug = SkillHubClient.requireSlug(rawSlug); + requireCommentId(commentId); + SimpleHttpClient.Response response = request( + "DELETE", "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, "删除评论失败"); + } + + public boolean starred(String rawSlug, String namespace) throws Exception { + String slug = SkillHubClient.requireSlug(rawSlug); + SimpleHttpClient.Response response = request( + "GET", "/api/v1/skills/" + encode(slug) + "/starred" + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, "获取收藏状态失败"); + return new JSONObject(response.body).optBoolean("starred"); + } + + public void setStarred(String rawSlug, String namespace, boolean starred) throws Exception { + String slug = SkillHubClient.requireSlug(rawSlug); + SimpleHttpClient.Response response = request( + starred ? "POST" : "DELETE", + "/api/v1/skills/" + encode(slug) + "/star" + namespaceQuery(namespace)); + requireAuthenticatedSuccess(response, starred ? "收藏失败" : "取消收藏失败"); + } + + private SimpleHttpClient.Response jsonRequest(String method, String path, String body) throws Exception { + if (body == null || body.length() > 16 * 1024) { + throw new IllegalArgumentException("SkillHub 请求内容过大"); + } + 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, "退出 SkillHub 账号失败"); + } + 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("SkillHub 账号响应过大"); + } + return response; + } + + private String sessionCookie() { + String cookie = CookieManager.getInstance().getCookie(API_ROOT); + return requireSafeCookie(cookie); + } + + static 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("无效的 SkillHub 会话"); + } + return cookie; + } + + static 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("SkillHub 账号信息不完整"); + } + 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 static 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 static void requireAuthenticatedSuccess( + SimpleHttpClient.Response response, String message) throws Exception { + if (response.code == 401) { + throw new IllegalStateException("请先登录 SkillHub 账号"); + } + requireSuccess(response, message); + } + + private static String requireCommentContent(String rawContent) { + String content = rawContent == null ? "" : rawContent.trim(); + if (content.length() == 0 || content.codePointCount(0, content.length()) > 500) { + throw new IllegalArgumentException("评论内容应为 1–500 字"); + } + return content; + } + + private static void requireCommentId(long commentId) { + if (commentId <= 0) { + throw new IllegalArgumentException("无效的评论 ID"); + } + } + + private static String publishError(SimpleHttpClient.Response response) { + String fallback = "发布 Skill 失败"; + 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 static String requireText(String rawValue, String label, int maxLength) { + String value = rawValue == null ? "" : rawValue.trim(); + if (value.length() == 0 || value.codePointCount(0, value.length()) > maxLength) { + throw new IllegalArgumentException(label + "不能为空且不能超过 " + maxLength + " 字"); + } + return value; + } + + private static 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("无效的 Skill 版本"); + } + return version; + } + + static List collectPublishFiles(SkillRecord skill) throws Exception { + File root = new File(skill.getRootPath()).getCanonicalFile(); + if (!root.isDirectory()) { + throw new IllegalArgumentException("本地 Skill 目录不存在"); + } + 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("本地 Skill 缺少 SKILL.md"); + } + return files; + } + + private static void collectPublishFiles( + File root, File current, List files, long[] total) throws Exception { + File[] children = current.listFiles(); + if (children == null) { + throw new IllegalArgumentException("无法读取本地 Skill 目录"); + } + for (File child : children) { + File canonical = child.getCanonicalFile(); + String rootPath = root.getPath(); + if (!canonical.getPath().startsWith(rootPath + File.separator)) { + throw new IllegalArgumentException("Skill 文件路径越界"); + } + 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("Skill 包含敏感文件:" + relative); + } + continue; + } + long length = canonical.length(); + if (length > MAX_PUBLISH_FILE_BYTES) { + throw new IllegalArgumentException("Skill 文件过大:" + relative); + } + total[0] += length; + if (total[0] > MAX_PUBLISH_TOTAL_BYTES) { + throw new IllegalArgumentException("Skill 文件总大小超过 10 MB"); + } + if (files.size() >= MAX_PUBLISH_FILES) { + throw new IllegalArgumentException("Skill 文件数量超过 200 个"); + } + 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 static 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("Skill 发布请求过大"); + } + 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 static String namespaceQuery(String namespace) { + String value = namespace == null ? "" : namespace.trim(); + return value.length() == 0 ? "" : "?namespace=" + encode(value); + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (Exception e) { + throw new IllegalArgumentException("无法编码 SkillHub 参数", e); + } + } + + private static void requireSuccess(SimpleHttpClient.Response response, String message) throws Exception { + if (response.code < 200 || response.code >= 300) { + throw new Exception(message + "(HTTP " + 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; } + } +} diff --git a/app/src/main/java/cn/lineai/mvp/ExtensionController.java b/app/src/main/java/cn/lineai/mvp/ExtensionController.java index 2134ed1f..4100ad27 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 { SkillRecord 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 3bec6913..74229753 100644 --- a/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java +++ b/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java @@ -96,6 +96,11 @@ SkillRecord installSkillFromGitHub(String location, String githubUrl) throws Exc return skill; } + 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/MainCoordinator.java b/app/src/main/java/cn/lineai/mvp/MainCoordinator.java index feb3b3ae..d5770b90 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 SkillRecord onSkillInstalledFromGitHub(String location, String githubUrl) return 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); diff --git a/app/src/main/java/cn/lineai/ui/MainChatView.java b/app/src/main/java/cn/lineai/ui/MainChatView.java index f541769a..ec4e834f 100644 --- a/app/src/main/java/cn/lineai/ui/MainChatView.java +++ b/app/src/main/java/cn/lineai/ui/MainChatView.java @@ -468,6 +468,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()); 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..a3dc8019 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, "在线商店"); + store.addRow(new ActionRowView( + context, + IconButtonView.ARCHIVE, + "SkillHub Skill 商店", + "浏览、搜索并安全安装社区 Skill", + 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/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/SkillHubCenterScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java new file mode 100644 index 00000000..c77b1036 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java @@ -0,0 +1,104 @@ +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, "SkillHub 功能中心", listener::onBack, null); + LinearLayout content = getContent(); + LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + + TextView notice = LineTheme.text(context, + "浏览、安装、评论和收藏使用 LineCode 原生界面;账号、创作者、企业及平台功能在受限 SkillHub 官方页面中完成。", + 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, "账号与社交"); + entry(content, "个人中心", "资料、已发布内容和账号概览", "account", listener); + entry(content, "我的收藏", "查看全部已收藏 Skill", "stars", listener); + entry(content, "我的关注", "查看关注的创作者与动态", "following", listener); + entry(content, "通知中心", "评论、审核和平台通知", "notifications", listener); + entry(content, "账号设置", "头像、绑定、通知与账号安全", "settings", listener); + entry(content, "实名认证", "发布和企业功能所需的官方认证", "verify", listener); + entry(content, "API Token", "创建、查看和撤销平台 Token", "tokens", listener); + + section(content, "创作者"); + entry(content, "创作者中心", "管理发布、审核状态、版本和申诉", "creator", listener); + entry(content, "官方发布工作台", "图标、GitHub 导入、认领和版本发布", "publish", listener); + + section(content, "发现"); + entry(content, "SkillSet", "浏览 Skill 组合与主题包", "skillsets", listener); + entry(content, "MCP Server", "搜索和查看 MCP Server", "mcp", listener); + entry(content, "Skill Hunt", "榜单、投票与称号", "skill-hunt", listener); + entry(content, "赛事", "赛事作品、排名和获奖信息", "contest", listener); + entry(content, "企业广场", "企业主页、热门 Skill 和关注", "enterprises", listener); + + section(content, "企业与平台"); + entry(content, "企业工作台", "团队 Skill、成员、审核和密钥", "enterprise-dashboard", listener); + entry(content, "企业发布", "发布和维护企业 Skill", "enterprise-publish", listener); + entry(content, "商户管理", "协议、商户状态和开发者密钥", "merchant", listener); + entry(content, "管理后台", "仅对 SkillHub 管理员账号开放", "admin", listener); + entry(content, "Skill 审核", "仅对有审核权限的账号开放", "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..5cddfd53 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java @@ -0,0 +1,189 @@ +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.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 LinearLayout { + 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 = new SkillHubSessionClient(); + 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; + setOrientation(VERTICAL); + setBackgroundColor(LineTheme.BG); + addView(new ScreenHeaderView(context, "SkillHub 官方登录", 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, + "凭据仅提交到 SkillHub 官方页面,LineCode 不读取验证码或密码。", + 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, "完成官方登录后将自动返回商店", + 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("SkillHub 官方登录页面"); + 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("已登录" + (name.length() == 0 ? "" : ":" + name)); + status.setTextColor(LineTheme.ACCENT); + Toast.makeText(getContext(), "SkillHub 登录成功", 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..aea77118 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java @@ -0,0 +1,202 @@ +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.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 = new SkillHubSessionClient(); + 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, "发布 Skill", listener::onBack, null); + this.listener = listener; + 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, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + addNotice(content); + selected = fieldButton(content); + slug = field(content, "Slug", "例如:my-skill", false); + displayName = field(content, "显示名称", "Skill 名称", false); + version = field(content, "版本", "例如:1.0.0", false); + + publish = LineTheme.textMedium(context, "发布到 SkillHub", + 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("没有可发布的本地 Skill"); + selected.setEnabled(false); + publish.setEnabled(false); + publish.setAlpha(0.45f); + } else { + select(0); + } + } + + private void addNotice(LinearLayout content) { + TextView notice = LineTheme.text(getContext(), + "将上传所选 Skill 目录中的文件。发布前会拒绝私钥、.env、凭据文件及超限内容;SkillHub 的实名认证等规则仍由官方服务校验。", + 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(), "选择本地 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("请先登录 SkillHub 账号"); + } + client.publish(skill, slugValue, nameValue, versionValue); + main.post(() -> { + setBusy(false); + Toast.makeText(getContext(), "Skill 发布成功", 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 ? "正在发布…" : "发布到 SkillHub"); + progress.setVisibility(busy ? VISIBLE : GONE); + } + + private static String safeMessage(Exception error) { + String message = error.getMessage(); + return message == null || message.trim().length() == 0 ? "发布 Skill 失败" : 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..5a5085f4 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java @@ -0,0 +1,169 @@ +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 LinearLayout { + 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 final Map DESTINATIONS = destinations(); + + private final WebView webView; + + @SuppressLint("SetJavaScriptEnabled") + public SkillHubWebScreenView(Context context, String destinationId, Runnable onBack) { + super(context); + Destination destination = requireDestination(destinationId); + 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, + "此功能由 SkillHub 官方页面提供,会话仅发送到允许的官方域名。", + 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) { + 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("SkillHub 完整详情", + "/skills/" + parts[1] + "/" + parts[2]); + } + } + if (destination == null) { + throw new IllegalArgumentException("无效的 SkillHub 功能入口"); + } + 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() { + HashMap values = new HashMap<>(); + add(values, "account", "个人中心", "/dashboard"); + add(values, "settings", "账号设置", "/dashboard/settings"); + add(values, "verify", "实名认证", "/dashboard/verify"); + add(values, "tokens", "API Token", "/dashboard/keys"); + add(values, "stars", "我的收藏", "/dashboard/stars"); + add(values, "following", "我的关注", "/dashboard/following"); + add(values, "notifications", "通知中心", "/notifications"); + add(values, "creator", "创作者中心", "/dashboard"); + add(values, "publish", "发布 Skill", "/dashboard/publish"); + add(values, "skillsets", "SkillSet", "/skillspackage"); + add(values, "mcp", "MCP Server", "/mcp"); + add(values, "skill-hunt", "Skill Hunt", "/skill-hunt"); + add(values, "contest", "赛事", "/contest"); + add(values, "enterprises", "企业广场", "/enterprise-zone"); + add(values, "enterprise-dashboard", "企业工作台", "/enterprise/dashboard"); + add(values, "enterprise-publish", "企业发布", "/enterprise/dashboard/publish"); + add(values, "merchant", "商户管理", "/admin/merchant"); + add(values, "admin", "管理后台", "/admin"); + add(values, "admin-reviews", "Skill 审核", "/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..b0c1402b --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java @@ -0,0 +1,64 @@ +package cn.lineai.ui.component; + +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.SkillHubClient; + +final class SkillIconLoader { + private static final int ICON_SIZE_PX = 192; + private static final LruCache CACHE = new LruCache<>(32); + private final SkillHubClient client = new SkillHubClient(); + private final Handler main = new Handler(Looper.getMainLooper()); + + 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..dd1d1be4 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java @@ -0,0 +1,1371 @@ +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.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 = new SkillHubClient(); + private final SkillHubSessionClient sessionClient = new SkillHubSessionClient(); + private final SkillIconLoader iconLoader = new SkillIconLoader(); + 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, "Skill 详情", listener::onBack, null); + this.slug = slug; + this.listener = listener; + body = getContent(); + LineTheme.padding(body, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + 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(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 14, LineTheme.BORDER)); + LineTheme.padding(hero, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + + 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("已认证", 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, "安全", 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 LinearLayout(getContext()); + actions.setOrientation(HORIZONTAL); + actions.setGravity(Gravity.CENTER_VERTICAL); + actions.addView(actionButton(IconButtonView.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) ? "取消收藏" : "收藏"; + 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, "分享", () -> 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, "完整功能", + () -> 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, "概述"); + addTab(tabs, TAB_FILES, "文件 · " + detail.getFiles().size()); + addTab(tabs, TAB_COMMENTS, "评论 · " + detail.getComments().size()); + addTab(tabs, TAB_VERSIONS, "版本"); + addTab(tabs, TAB_EVALUATION, "评测报告"); + addTab(tabs, 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("Skill 信息", IconButtonView.BOXES); + LinearLayout primary = new LinearLayout(getContext()); + primary.setOrientation(HORIZONTAL); + primary.addView(metadataCard("分类", 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("来源", sourceName(value.getSource())), sourceParams); + addSectionContent(meta, primary); + + LinearLayout secondary = new LinearLayout(getContext()); + secondary.setOrientation(HORIZONTAL); + secondary.addView(metadataCard("版本", "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("更新", formatDate(value.getUpdatedAt())), dateParams); + addSectionContent(meta, secondary); + if (!value.getSubCategories().isEmpty()) { + addSectionContent(meta, metadataRow("子分类", join(value.getSubCategories()))); + } + if (!value.getTags().isEmpty()) { + addSectionContent(meta, metadataRow("标签", join(value.getTags()))); + } + addSection(meta); + + addSecurity(value); + LinearLayout content = section("SKILL.md", IconButtonView.FILE_TEXT); + TextView zoomHint = LineTheme.text(getContext(), "双指缩放可调整文档字号", + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL); + addSectionContent(content, zoomHint); + if (value.getMarkdown().trim().length() == 0) { + addSectionContent(content, emptyText("该版本暂无公开 Skill 文档")); + } 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("安全检查", + unsafe ? IconButtonView.CIRCLE_ALERT : IconButtonView.SHIELD_CHECK); + String text = value.getSecurityStatusText().length() > 0 + ? value.getSecurityStatusText() + : unsafe ? "需要在使用前检查 Skill 内容" : "未发现明确的安全风险"; + if (value.hasScripts()) { + text += "\n包含 scripts/ 或脚本文件;安装只会落盘,不会自动执行。"; + } + if (value.requiresApiKey()) { + text += "\n可能需要自行配置 API Key,请勿将密钥写入 Skill 文件。"; + } + 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("文件清单 · " + value.getFiles().size() + " 个", + IconButtonView.FILE_TEXT); + addSectionContent(section, LineTheme.text(getContext(), "点击文件可预览公开文本内容", + LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL)); + if (value.getFiles().isEmpty()) { + addSectionContent(section, emptyText("该版本没有公开文件")); + } 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("预览 " + 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 ? "根目录" : 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(), "该文件类型不支持文本预览", Toast.LENGTH_SHORT).show(); + return; + } + row.setEnabled(false); + Toast.makeText(getContext(), "正在加载文件…", 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(), "双指缩放可调整文档字号", + 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 AlertDialog.Builder(getContext()) + .setTitle(path) + .setView(host) + .setNegativeButton("关闭", null) + .setPositiveButton("复制", (dialog, which) -> { + ShareHelper.copy(getContext(), content); + Toast.makeText(getContext(), "文件内容已复制", Toast.LENGTH_SHORT).show(); + }) + .show(); + } + + private void addComments(SkillHubModels.Detail value) { + LinearLayout section = section("社区评论", IconButtonView.MESSAGE_CIRCLE); + TextView compose = LineTheme.textMedium(getContext(), "发表评论", + 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("暂无公开评论,登录后可发表第一条评论")); + } 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(), "请先登录 SkillHub 账号", 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 ? "发表评论" : "回复 " + + (parent.getAuthor().length() == 0 ? "SkillHub 用户" : parent.getAuthor()); + panel.addView(LineTheme.textMedium(getContext(), dialogTitle, + LineTheme.FONT_LG, LineTheme.TEXT)); + TextView hint = LineTheme.text(getContext(), "评论提交后需经过 SkillHub 审核,最多 500 字。", + 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 ? "分享你的使用体验…" : "写下回复…"); + 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 LinearLayout(getContext()); + actions.setOrientation(HORIZONTAL); + TextView cancel = dialogButton("取消", false); + TextView submit = dialogButton("提交评论", 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(), "评论内容应为 1–500 字", Toast.LENGTH_SHORT).show(); + return; + } + submit.setEnabled(false); + cancel.setEnabled(false); + input.setEnabled(false); + submit.setText("正在提交…"); + 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(), "评论已提交,审核通过后展示", + Toast.LENGTH_LONG).show(); + load(); + }); + } catch (Exception e) { + main.post(() -> { + submit.setEnabled(true); + cancel.setEnabled(true); + input.setEnabled(true); + submit.setText("提交评论"); + 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 ? "SkillHub 用户" : 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 LinearLayout(getContext()); + actions.setOrientation(HORIZONTAL); + TextView like = commentAction( + (comment.isLiked() ? "取消赞" : "赞") + + (comment.getLikeCount() > 0 ? " " + comment.getLikeCount() : "")); + like.setOnClickListener(v -> updateCommentLike(comment, !comment.isLiked(), like)); + actions.addView(like); + TextView reply = commentAction("回复"); + reply.setOnClickListener(v -> checkSessionForComment(detail, comment)); + actions.addView(reply); + if (comment.getReplyCount() > comment.getReplies().size()) { + TextView allReplies = commentAction("全部回复 " + comment.getReplyCount()); + allReplies.setOnClickListener(v -> loadCommentReplies(section, comment, allReplies)); + actions.addView(allReplies); + } + TextView delete = commentAction("删除"); + 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("版本历史", IconButtonView.CLOCK_3); + if (value.getVersions().isEmpty()) { + addSectionContent(section, emptyText("暂无版本历史")); + } 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("SkillHub 评测报告", IconButtonView.FLASK_CONICAL); + SkillHubModels.Evaluation evaluation = value.getEvaluation(); + if (evaluation == null || evaluation.getStatus().length() == 0) { + addSectionContent(section, emptyText("该 Skill 暂无公开评测报告")); + } else { + if (evaluation.getScore() > 0) { + addSectionContent(section, LineTheme.textMedium(getContext(), + String.format(Locale.getDefault(), "综合评分 %.1f", 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("效果预览", IconButtonView.PLAY); + if (value.getTestCases().isEmpty()) { + addSectionContent(section, emptyText("该 Skill 暂无公开使用示例")); + } else { + for (SkillHubModels.TestCase testCase : value.getTestCases()) { + TextView prompt = LineTheme.textMedium(getContext(), + testCase.getTitle() + "\n用户:" + 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 ? "—" : 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(), "选择位置并安装", + LineTheme.FONT_MD, LineTheme.TEXT_ON_COLOR); + install.setGravity(Gravity.CENTER); + install.setClickable(true); + install.setFocusable(true); + install.setContentDescription("安装 " + 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(), "请先登录 SkillHub 账号", 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 ? "收藏成功" : "已取消收藏", 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) ? "取消收藏" : "收藏"); + } + starButton.setContentDescription( + Boolean.TRUE.equals(starred) ? "取消收藏" : "收藏"); + } + + private void copyPrompt(SkillHubModels.Detail value) { + ShareHelper.copy(getContext(), installPrompt(value)); + Toast.makeText(getContext(), "安装 Prompt 已复制", 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 "请从 SkillHub 安装 Skill:" + 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(), "仅允许打开 HTTPS 链接", Toast.LENGTH_SHORT).show(); + return; + } + getContext().startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } catch (Exception e) { + Toast.makeText(getContext(), "无法打开链接", 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(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 10)); + 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(), "安装 " + value.getName(), + LineTheme.FONT_LG, LineTheme.TEXT); + title.setMaxLines(2); + titleCopy.addView(title); + TextView subtitle = LineTheme.text(getContext(), "选择 Skill 的安装范围", + 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, 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(), "安装位置", + 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, "App 全局 Skills", "所有工作区均可使用", true); + LinearLayout projectOption = installLocationOption( + IconButtonView.FOLDER, "当前项目", ".linecode/skills · 仅当前工作区", 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() + ? "包含脚本文件。安装只会落盘,不会自动执行;使用前请检查内容。" + : "该 Skill 可能需要 API Key,请勿将密钥写入 Skill 文件。"; + if (value.hasScripts() && value.requiresApiKey()) { + warning += "\n可能还需要自行配置 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 LinearLayout(getContext()); + actions.setOrientation(HORIZONTAL); + TextView cancel = dialogButton("取消", false); + TextView install = dialogButton("安装", 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("正在安装…"); + installButton.setText("正在安装…"); + new Thread(() -> { + try { + listener.onInstall(location, value.getSlug(), value.getVersion()); + main.post(() -> { + dialog.dismiss(); + installButton.setText("已安装"); + Toast.makeText(getContext(), "Skill 安装成功", + 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("安装"); + installButton.setText("选择位置并安装"); + 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("详情加载失败", IconButtonView.CIRCLE_ALERT); + TextView message = LineTheme.text(getContext(), safeMessage(error) + "\n点此重试", + 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) ? "SkillHub 社区" : 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 String.format(Locale.getDefault(), "%.1f万", 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 "—"; + } + return DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) + .format(new Date(value)); + } + + private String safeMessage(Exception error) { + return error.getMessage() == null ? "未知错误" : 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..f8264838 --- /dev/null +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java @@ -0,0 +1,644 @@ +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.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; +import java.util.Locale; + +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 = new SkillHubClient(); + private final SkillHubSessionClient sessionClient = new SkillHubSessionClient(); + private final SkillIconLoader iconLoader = new SkillIconLoader(); + 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, "Skill 商店", listener::onBack, null); + this.listener = listener; + LinearLayout content = getContent(); + LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + + 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(), "发现社区 Skills", + LineTheme.FONT_XL, LineTheme.TEXT)); + TextView description = LineTheme.text(getContext(), + "浏览、检查并安装来自 SkillHub 的社区能力", + 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(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 12)); + 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("搜索名称、描述或作者"); + 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, "热门下载", "downloads"); + addFilter(filters, "最多收藏", "stars"); + addFilter(filters, "最近更新", "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("按" + 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("SkillHub 账号"); + 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(), "正在检查 SkillHub 账号…", + LineTheme.FONT_SM, LineTheme.TEXT); + copy.addView(accountTitle); + accountSubtitle = LineTheme.text(getContext(), "登录由 SkillHub 官方页面处理", + 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(), "SkillHub 功能中心", + 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("正在检查 SkillHub 账号…"); + accountSubtitle.setText("登录由 SkillHub 官方页面处理"); + 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("账号状态检查失败"); + accountSubtitle.setText("点此重试 · " + 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("登录 SkillHub 账号"); + accountSubtitle.setText("登录后可发布、评论和收藏 Skill"); + accountAction.setIconType(IconButtonView.EXTERNAL_LINK); + return; + } + publishButton.setVisibility(VISIBLE); + SkillHubSessionClient.Account account = session.getAccount(); + accountTitle.setText(account.getDisplayName()); + accountSubtitle.setText(account.getHandle().length() == 0 + ? "SkillHub 已登录" : "@" + account.getHandle() + " · 已登录"); + 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(), "SkillHub 账号已连接", + 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 LinearLayout(getContext()); + actions.setOrientation(HORIZONTAL); + TextView close = accountDialogButton("继续使用", false); + TextView logout = accountDialogButton("退出登录", 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("正在退出…"); + new Thread(() -> { + try { + sessionClient.logout(); + main.post(() -> { + dialog.dismiss(); + renderAccount(SkillHubSessionClient.Session.signedOut()); + Toast.makeText(getContext(), "已退出 SkillHub 账号", + Toast.LENGTH_SHORT).show(); + }); + } catch (Exception e) { + main.post(() -> { + close.setEnabled(true); + logout.setEnabled(true); + logout.setText("退出登录"); + 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("上一页"); + previous.setOnClickListener(v -> { + if (page > 1) { + page--; + load(); + } + }); + pager.addView(previous); + TextView next = pagerButton("下一页"); + 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("正在加载第 " + 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("加载失败,点此重试\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() + ? "没有找到匹配的 Skill" + : "第 " + requestedPage + " 页 · 共 " + formatCount(value.getTotal()) + " 个 Skill"); + 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() + ",查看详情"); + card.setOnClickListener(v -> listener.onOpen(skill.getSlug())); + card.setBackground(LineTheme.roundedStroke( + getContext(), LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); + LineTheme.padding(card, LineTheme.MD, LineTheme.MD, LineTheme.SM, LineTheme.MD); + 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); + icon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 12)); + 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(1); + titleRow.addView(title, new LinearLayout.LayoutParams( + 0, LayoutParams.WRAP_CONTENT, 1f)); + if (skill.isVerified()) { + titleRow.addView(tag("已认证", 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("需要 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); + } + copy.addView(tags, tagsParams); + } + + 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); + copy.addView(stats, statsParams); + + 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 String.format(Locale.getDefault(), "%.1f万", value / 10000d); + } + return String.valueOf(value); + } + + private String safeMessage(Exception e) { + return e.getMessage() == null ? "未知错误" : 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/test/java/cn/lineai/data/service/SkillHubClientTest.java b/app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java new file mode 100644 index 00000000..11f3d98e --- /dev/null +++ b/app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java @@ -0,0 +1,133 @@ +package cn.lineai.data.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import cn.lineai.model.SkillHubModels; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public final class SkillHubClientTest { + @Test + public void parseSummaryPrefersChineseDescriptionAndMapsRiskFields() throws Exception { + JSONObject value = new JSONObject() + .put("slug", "pdf-helper") + .put("name", "PDF Helper") + .put("description", "English") + .put("description_zh", "中文说明") + .put("ownerName", "author") + .put("category", "office") + .put("source", "community") + .put("version", "1.2.0") + .put("iconUrl", "https://skillhub.cn/icons/pdf-helper.png") + .put("downloads", 42) + .put("stars", 7) + .put("updated_at", 99) + .put("verified", true) + .put("labels", new JSONObject().put("requires_api_key", "true")) + .put("subCategories", new JSONArray() + .put(new JSONObject().put("key", "pdf").put("name", "PDF 工具"))); + + SkillHubModels.Summary summary = SkillHubClient.parseSummary(value); + + assertEquals("pdf-helper", summary.getSlug()); + assertEquals("中文说明", summary.getDescription()); + assertEquals("PDF 工具", summary.getSubCategories().get(0)); + assertEquals("https://skillhub.cn/icons/pdf-helper.png", summary.getIconUrl()); + assertTrue(summary.isVerified()); + assertTrue(summary.requiresApiKey()); + } + + @Test(expected = IllegalArgumentException.class) + public void parseSummaryRejectsUnsafeSlug() throws Exception { + SkillHubClient.parseSummary(new JSONObject().put("slug", "../escape")); + } + + @Test + public void iconUrlAllowsOnlySkillHubHttpsHosts() { + assertEquals( + "https://api.skillhub.cn/icons/skill.png", + SkillHubClient.requireIconUrl("https://api.skillhub.cn/icons/skill.png")); + assertEquals( + "https://cloudcache.tencent-cloud.com/qcloud/ui/icon.png", + SkillHubClient.requireIconUrl("https://cloudcache.tencent-cloud.com/qcloud/ui/icon.png")); + assertEquals( + "https://skillhub-1388575217.cos.accelerate.myqcloud.com/skill-icons/icon.png", + SkillHubClient.requireIconUrl( + "https://skillhub-1388575217.cos.accelerate.myqcloud.com/skill-icons/icon.png")); + assertRejectedIconUrl("http://skillhub.cn/icons/skill.png"); + assertRejectedIconUrl("https://skillhub.cn.evil.example/icons/skill.png"); + assertRejectedIconUrl("https://example.com/icons/skill.png"); + } + + + @Test + public void filePathAllowsNestedRelativePaths() { + assertEquals("references/api.md", SkillHubClient.requireFilePath("references/api.md")); + assertEquals("SKILL.md", SkillHubClient.requireFilePath("SKILL.md")); + } + + @Test(expected = IllegalArgumentException.class) + public void filePathRejectsTraversal() { + SkillHubClient.requireFilePath("references/../SKILL.md"); + } + + @Test(expected = IllegalArgumentException.class) + public void filePathRejectsAbsolutePath() { + SkillHubClient.requireFilePath("/SKILL.md"); + } + + @Test(expected = IllegalArgumentException.class) + public void filePathRejectsBackslashes() { + SkillHubClient.requireFilePath("references\\api.md"); + } + @Test + public void parseCommentMapsPublicReplies() throws Exception { + JSONObject reply = new JSONObject() + .put("id", 2) + .put("authorName", "回复者") + .put("content", "回复") + .put("createdAt", 20); + JSONObject value = new JSONObject() + .put("id", 1) + .put("authorName", "作者") + .put("content", "评论") + .put("createdAt", 10) + .put("likeCount", 3) + .put("replies", new JSONObject().put("preview", new JSONArray().put(reply))); + + SkillHubModels.Comment comment = SkillHubClient.parseComment(value); + + assertEquals("作者", comment.getAuthor()); + assertEquals("评论", comment.getContent()); + assertEquals(3, comment.getLikeCount()); + assertEquals(1, comment.getReplies().size()); + assertEquals("回复", comment.getReplies().get(0).getContent()); + } + + private static void assertRejectedIconUrl(String value) { + try { + SkillHubClient.requireIconUrl(value); + } catch (IllegalArgumentException expected) { + return; + } + throw new AssertionError("Expected icon URL rejection: " + value); + } + + @Test + public void detailDetectsScripts() { + SkillHubModels.Summary summary = new SkillHubModels.Summary( + "safe", "Safe", "", "", "", "community", "1.0.0", "", + 0, 0, 0, false, false, null); + SkillHubModels.Detail detail = new SkillHubModels.Detail( + summary, "@owner/safe", "", "benign", "安全", + java.util.Arrays.asList( + new SkillHubModels.FileEntry("SKILL.md", "", 1), + new SkillHubModels.FileEntry("scripts/run.py", "", 2))); + + assertTrue(detail.hasScripts()); + assertFalse(detail.getFiles().isEmpty()); + } +} diff --git a/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java b/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java new file mode 100644 index 00000000..604b79f9 --- /dev/null +++ b/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java @@ -0,0 +1,90 @@ +package cn.lineai.data.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import cn.lineai.model.SkillRecord; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import org.json.JSONObject; +import org.junit.Test; + +public final class SkillHubSessionClientTest { + @Test + public void parsesNestedUserAccount() throws Exception { + JSONObject root = new JSONObject().put("user", new JSONObject() + .put("displayName", "测试用户") + .put("handle", "tester") + .put("avatarUrl", "https://skillhub.cn/avatar.png")); + + SkillHubSessionClient.Account account = SkillHubSessionClient.parseAccount(root); + + assertEquals("测试用户", account.getDisplayName()); + assertEquals("tester", account.getHandle()); + assertEquals("https://skillhub.cn/avatar.png", account.getAvatarUrl()); + } + + @Test + public void parsesFallbackUserFields() throws Exception { + JSONObject root = new JSONObject() + .put("nickname", "昵称") + .put("username", "name"); + + SkillHubSessionClient.Account account = SkillHubSessionClient.parseAccount(root); + + assertEquals("昵称", account.getDisplayName()); + assertEquals("name", account.getHandle()); + } + + @Test + public void collectsPublishFilesFromLocalSkill() throws Exception { + File root = Files.createTempDirectory("skillhub-publish").toFile(); + write(root, "SKILL.md", "# Test"); + write(root, "references/guide.md", "Guide"); + SkillRecord skill = skill(root); + + List files = + SkillHubSessionClient.collectPublishFiles(skill); + + assertEquals(2, files.size()); + assertEquals("SKILL.md", files.get(0).path); + assertEquals("references/guide.md", files.get(1).path); + } + + @Test + public void rejectsSensitivePublishFile() throws Exception { + File root = Files.createTempDirectory("skillhub-sensitive").toFile(); + write(root, "SKILL.md", "# Test"); + write(root, ".env", "TOKEN=value"); + + try { + SkillHubSessionClient.collectPublishFiles(skill(root)); + } catch (IllegalArgumentException error) { + assertTrue(error.getMessage().contains("敏感文件")); + return; + } + throw new AssertionError("Expected sensitive file rejection"); + } + + private static SkillRecord skill(File root) { + return new SkillRecord("test", "Test", "Description", + root.getAbsolutePath(), new File(root, "SKILL.md").getAbsolutePath(), + SkillRecord.LOCATION_APP, true, 0, 0); + } + + private static void write(File root, String relative, String content) throws Exception { + File file = new File(root, relative); + File parent = file.getParentFile(); + if (parent != null) { + assertTrue(parent.mkdirs() || parent.isDirectory()); + } + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsCookieHeaderInjection() { + SkillHubSessionClient.requireSafeCookie("sid=value\r\nX-Test: injected"); + } +} diff --git a/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java b/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java new file mode 100644 index 00000000..740b2d8a --- /dev/null +++ b/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java @@ -0,0 +1,92 @@ +package cn.lineai.mvp; + +import cn.lineai.data.repository.ExtensionStore; +import cn.lineai.model.SkillRecord; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Test; + +public final class ExtensionManagementControllerTest { + @Test + public void skillHubInstallFromWorkerThreadDoesNotTouchHost() throws Exception { + SkillRecord expected = new SkillRecord( + "skill-id", + "Skill", + "Description", + "/repo/.linecode/skills/skill", + "/repo/.linecode/skills/skill/SKILL.md", + SkillRecord.LOCATION_PROJECT, + true, + 1L, + 1L); + AtomicReference repositoryThread = new AtomicReference<>(); + ExtensionStore store = (ExtensionStore) Proxy.newProxyInstance( + ExtensionStore.class.getClassLoader(), + new Class[]{ExtensionStore.class}, + (proxy, method, args) -> { + if ("installSkillFromSkillHub".equals(method.getName())) { + repositoryThread.set(Thread.currentThread()); + Assert.assertArrayEquals( + new Object[]{"/repo", SkillRecord.LOCATION_PROJECT, "demo-skill", "1.0.0"}, + args); + return expected; + } + throw new AssertionError("Unexpected repository call: " + method.getName()); + }); + FakeHost host = new FakeHost(); + ExtensionManagementController controller = new ExtensionManagementController( + store, + null, + null, + host); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + + Thread worker = new Thread(() -> { + try { + result.set(controller.installSkillFromSkillHub( + SkillRecord.LOCATION_PROJECT, + "demo-skill", + "1.0.0")); + } catch (Throwable error) { + failure.set(error); + } + }, "skillhub-install-test"); + worker.start(); + worker.join(); + + Assert.assertNull(failure.get()); + Assert.assertSame(expected, result.get()); + Assert.assertSame(worker, repositoryThread.get()); + Assert.assertEquals(0, host.navigationCount); + Assert.assertEquals(0, host.refreshCount); + Assert.assertEquals(0, host.renderCount); + } + + private static final class FakeHost implements ExtensionManagementController.Host { + private int navigationCount; + private int refreshCount; + private int renderCount; + + @Override + public String projectPath() { + return "/repo"; + } + + @Override + public void returnToScreen(String screenId) { + navigationCount++; + } + + @Override + public void refreshVisibleScreen(String screenId) { + refreshCount++; + } + + @Override + public void render() { + renderCount++; + } + } +} diff --git a/core-model/src/main/java/cn/lineai/model/SkillHubModels.java b/core-model/src/main/java/cn/lineai/model/SkillHubModels.java new file mode 100644 index 00000000..a8328bb2 --- /dev/null +++ b/core-model/src/main/java/cn/lineai/model/SkillHubModels.java @@ -0,0 +1,281 @@ +package cn.lineai.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SkillHubModels { + private SkillHubModels() { + } + + public static class Summary { + private final String slug; + private final String name; + private final String description; + private final String owner; + private final String category; + private final String source; + private final String version; + private final String iconUrl; + private final long downloads; + private final long stars; + private final long updatedAt; + private final boolean verified; + private final boolean requiresApiKey; + private final List subCategories; + + public Summary(String slug, String name, String description, String owner, String category, + String source, String version, String iconUrl, long downloads, long stars, + long updatedAt, boolean verified, boolean requiresApiKey, + List subCategories) { + this.slug = safe(slug); + this.name = safe(name); + this.description = safe(description); + this.owner = safe(owner); + this.category = safe(category); + this.source = safe(source); + this.version = safe(version); + this.iconUrl = safe(iconUrl); + this.downloads = Math.max(0, downloads); + this.stars = Math.max(0, stars); + this.updatedAt = Math.max(0, updatedAt); + this.verified = verified; + this.requiresApiKey = requiresApiKey; + this.subCategories = immutable(subCategories); + } + + public String getSlug() { return slug; } + public String getName() { return name; } + public String getDescription() { return description; } + public String getOwner() { return owner; } + public String getCategory() { return category; } + public String getSource() { return source; } + public String getVersion() { return version; } + public String getIconUrl() { return iconUrl; } + public long getDownloads() { return downloads; } + public long getStars() { return stars; } + public long getUpdatedAt() { return updatedAt; } + public boolean isVerified() { return verified; } + public boolean requiresApiKey() { return requiresApiKey; } + public List getSubCategories() { return subCategories; } + } + + public static final class Detail extends Summary { + private final String canonicalName; + private final String publisher; + private final String securityStatus; + private final String securityStatusText; + private final String markdown; + private final List tags; + private final List files; + private final List comments; + private final List versions; + private final Evaluation evaluation; + private final List testCases; + + public Detail(Summary summary, String canonicalName, String publisher, + String securityStatus, String securityStatusText, List files) { + this(summary, canonicalName, publisher, securityStatus, securityStatusText, + "", null, files, null, null, null, null); + } + + public Detail(Summary summary, String canonicalName, String publisher, + String securityStatus, String securityStatusText, String markdown, + List tags, List files, List comments, + List versions, Evaluation evaluation, List testCases) { + super(summary.getSlug(), summary.getName(), summary.getDescription(), summary.getOwner(), + summary.getCategory(), summary.getSource(), summary.getVersion(), summary.getIconUrl(), + summary.getDownloads(), summary.getStars(), summary.getUpdatedAt(), summary.isVerified(), + summary.requiresApiKey(), summary.getSubCategories()); + this.canonicalName = safe(canonicalName); + this.publisher = safe(publisher); + this.securityStatus = safe(securityStatus); + this.securityStatusText = safe(securityStatusText); + this.markdown = safe(markdown); + this.tags = immutable(tags); + this.files = immutable(files); + this.comments = immutable(comments); + this.versions = immutable(versions); + this.evaluation = evaluation; + this.testCases = immutable(testCases); + } + + public String getCanonicalName() { return canonicalName; } + public String getPublisher() { return publisher; } + public String getSecurityStatus() { return securityStatus; } + public String getSecurityStatusText() { return securityStatusText; } + public String getMarkdown() { return markdown; } + public List getTags() { return tags; } + public List getFiles() { return files; } + public List getComments() { return comments; } + public List getVersions() { return versions; } + public Evaluation getEvaluation() { return evaluation; } + public List getTestCases() { return testCases; } + public boolean hasScripts() { + for (FileEntry file : files) { + if (file.getPath().startsWith("scripts/") || file.getPath().endsWith(".sh")) { + return true; + } + } + return false; + } + } + + public static final class Comment { + private final long id; + private final long userId; + private final long parentId; + private final String author; + private final String handle; + private final String avatarUrl; + private final String content; + private final long createdAt; + private final long likeCount; + private final long replyCount; + private final boolean liked; + private final String status; + private final List imageUrls; + private final List replies; + + public Comment(long id, String author, String content, long createdAt, + long likeCount, List replies) { + this(id, 0, 0, author, "", "", content, createdAt, likeCount, + replies == null ? 0 : replies.size(), false, "", null, replies); + } + + public Comment(long id, long userId, long parentId, String author, + String handle, String avatarUrl, String content, long createdAt, + long likeCount, long replyCount, boolean liked, String status, + List imageUrls, List replies) { + this.id = Math.max(0, id); + this.userId = Math.max(0, userId); + this.parentId = Math.max(0, parentId); + this.author = safe(author); + this.handle = safe(handle); + this.avatarUrl = safe(avatarUrl); + this.content = safe(content); + this.createdAt = Math.max(0, createdAt); + this.likeCount = Math.max(0, likeCount); + this.replyCount = Math.max(0, replyCount); + this.liked = liked; + this.status = safe(status); + this.imageUrls = immutable(imageUrls); + this.replies = immutable(replies); + } + + public long getId() { return id; } + public long getUserId() { return userId; } + public long getParentId() { return parentId; } + public String getAuthor() { return author; } + public String getHandle() { return handle; } + public String getAvatarUrl() { return avatarUrl; } + public String getContent() { return content; } + public long getCreatedAt() { return createdAt; } + public long getLikeCount() { return likeCount; } + public long getReplyCount() { return replyCount; } + public boolean isLiked() { return liked; } + public String getStatus() { return status; } + public List getImageUrls() { return imageUrls; } + public List getReplies() { return replies; } + } + + public static final class Version { + private final String version; + private final String changelog; + private final long createdAt; + private final String securityStatus; + private final String securityStatusText; + + public Version(String version, String changelog, long createdAt, + String securityStatus, String securityStatusText) { + this.version = safe(version); + this.changelog = safe(changelog); + this.createdAt = Math.max(0, createdAt); + this.securityStatus = safe(securityStatus); + this.securityStatusText = safe(securityStatusText); + } + + public String getVersion() { return version; } + public String getChangelog() { return changelog; } + public long getCreatedAt() { return createdAt; } + public String getSecurityStatus() { return securityStatus; } + public String getSecurityStatusText() { return securityStatusText; } + } + + public static final class Evaluation { + private final String status; + private final double score; + private final String summary; + private final List highlights; + private final List suggestions; + + public Evaluation(String status, double score, String summary, + List highlights, List suggestions) { + this.status = safe(status); + this.score = Math.max(0, score); + this.summary = safe(summary); + this.highlights = immutable(highlights); + this.suggestions = immutable(suggestions); + } + + public String getStatus() { return status; } + public double getScore() { return score; } + public String getSummary() { return summary; } + public List getHighlights() { return highlights; } + public List getSuggestions() { return suggestions; } + } + + public static final class TestCase { + private final String title; + private final String prompt; + private final String expected; + + public TestCase(String title, String prompt, String expected) { + this.title = safe(title); + this.prompt = safe(prompt); + this.expected = safe(expected); + } + + public String getTitle() { return title; } + public String getPrompt() { return prompt; } + public String getExpected() { return expected; } + } + + public static final class FileEntry { + private final String path; + private final String sha256; + private final long size; + + public FileEntry(String path, String sha256, long size) { + this.path = safe(path); + this.sha256 = safe(sha256); + this.size = Math.max(0, size); + } + + public String getPath() { return path; } + public String getSha256() { return sha256; } + public long getSize() { return size; } + } + + public static final class Page { + private final List skills; + private final long total; + + public Page(List skills, long total) { + this.skills = immutable(skills); + this.total = Math.max(0, total); + } + + public List getSkills() { return skills; } + public long getTotal() { return total; } + } + + private static String safe(String value) { + return value == null ? "" : value; + } + + private static List immutable(List values) { + return Collections.unmodifiableList(new ArrayList<>(values == null + ? Collections.emptyList() : values)); + } +} diff --git a/core-security/src/main/java/cn/lineai/security/SimpleHttpClient.java b/core-security/src/main/java/cn/lineai/security/SimpleHttpClient.java index 8b534c80..9f2bac2d 100644 --- a/core-security/src/main/java/cn/lineai/security/SimpleHttpClient.java +++ b/core-security/src/main/java/cn/lineai/security/SimpleHttpClient.java @@ -106,13 +106,15 @@ public static Response execute(Request request) throws Exception { for (Map.Entry entry : request.headers.entrySet()) { connection.setRequestProperty(entry.getKey(), entry.getValue()); } - if (request.body != null) { + byte[] requestBytes = request.bodyBytes != null + ? request.bodyBytes + : request.body == null ? null : request.body.getBytes(StandardCharsets.UTF_8); + if (requestBytes != null) { connection.setDoOutput(true); - byte[] bytes = request.body.getBytes(StandardCharsets.UTF_8); - connection.setFixedLengthStreamingMode(bytes.length); + connection.setFixedLengthStreamingMode(requestBytes.length); OutputStream output = connection.getOutputStream(); try { - output.write(bytes); + output.write(requestBytes); } finally { output.close(); } @@ -183,6 +185,7 @@ public static final class Request { public String url; public String method; public String body; + public byte[] bodyBytes; public int connectTimeoutMs = 15000; public int readTimeoutMs = 30000; public final LinkedHashMap headers = new LinkedHashMap<>(); diff --git a/data/src/main/java/cn/lineai/data/repository/ExtensionStore.java b/data/src/main/java/cn/lineai/data/repository/ExtensionStore.java index 52266ee2..a823d237 100644 --- a/data/src/main/java/cn/lineai/data/repository/ExtensionStore.java +++ b/data/src/main/java/cn/lineai/data/repository/ExtensionStore.java @@ -90,6 +90,11 @@ public interface ExtensionStore { */ SkillRecord installSkillFromGitHub(String homePath, String location, String githubUrl) throws Exception; + /** + * 从 SkillHub 下载并安装指定版本的 Skill。 + */ + SkillRecord installSkillFromSkillHub(String homePath, String location, String slug, String version) throws Exception; + /** * 设置 Skill 启用状态。 */ diff --git a/markdown/src/main/java/cn/lineai/ui/markdown/MarkdownView.java b/markdown/src/main/java/cn/lineai/ui/markdown/MarkdownView.java index 74d9d6b1..96958940 100644 --- a/markdown/src/main/java/cn/lineai/ui/markdown/MarkdownView.java +++ b/markdown/src/main/java/cn/lineai/ui/markdown/MarkdownView.java @@ -2,22 +2,40 @@ import android.content.Context; import android.graphics.Typeface; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.TextView; import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; import org.commonmark.Extension; import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; public final class MarkdownView extends LinearLayout { + public interface TextScaleListener { + void onTextScaleChanged(float scale); + } + + private static final float MIN_TEXT_SCALE = 0.5f; + private static final float MAX_TEXT_SCALE = 1.6f; + private final Parser parser; private final MarkdownRenderer renderer; + private final ScaleGestureDetector scaleDetector; + private final Map baseTextSizes = new IdentityHashMap<>(); private String lastMarkdown; private boolean plainMode; private boolean codeWrapEnabled; + private boolean pinchZoomEnabled; + private float textScale = 1f; private MarkdownLinkHandler linkHandler; + private TextScaleListener textScaleListener; public MarkdownView(Context context) { super(context); @@ -26,6 +44,57 @@ public MarkdownView(Context context) { Iterable extensions = Collections.singletonList(TablesExtension.create()); parser = Parser.builder().extensions(extensions).build(); renderer = new MarkdownRenderer(context); + scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector detector) { + float next = Math.max(MIN_TEXT_SCALE, + Math.min(MAX_TEXT_SCALE, textScale * detector.getScaleFactor())); + if (Math.abs(next - textScale) < 0.005f) { + return true; + } + textScale = next; + applyTextScale(MarkdownView.this); + if (textScaleListener != null) { + textScaleListener.onTextScaleChanged(textScale); + } + return true; + } + }); + } + + public void setPinchZoomEnabled(boolean enabled) { + pinchZoomEnabled = enabled; + } + + public void setTextScale(float scale) { + float next = Math.max(MIN_TEXT_SCALE, Math.min(MAX_TEXT_SCALE, scale)); + if (Math.abs(next - textScale) < 0.005f) { + return; + } + textScale = next; + applyTextScale(this); + } + + public void setTextScaleListener(TextScaleListener listener) { + textScaleListener = listener; + } + + public float getTextScale() { + return textScale; + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (pinchZoomEnabled && (event.getPointerCount() > 1 || scaleDetector.isInProgress())) { + getParent().requestDisallowInterceptTouchEvent(true); + scaleDetector.onTouchEvent(event); + if (event.getActionMasked() == MotionEvent.ACTION_UP + || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + getParent().requestDisallowInterceptTouchEvent(false); + } + return true; + } + return super.dispatchTouchEvent(event); } public void setCodeWrapEnabled(boolean enabled) { @@ -90,10 +159,33 @@ private void rerender() { private void renderValue(String value) { removeAllViews(); + baseTextSizes.clear(); if (value.trim().length() == 0) { return; } Node document = parser.parse(value); renderer.renderInto(this, document); + if (textScale != 1f) { + applyTextScale(this); + } + } + + private void applyTextScale(View view) { + if (view instanceof TextView) { + TextView text = (TextView) view; + Float baseSp = baseTextSizes.get(text); + if (baseSp == null) { + baseSp = text.getTextSize() / getResources().getDisplayMetrics().scaledDensity; + baseTextSizes.put(text, baseSp); + } + text.setTextSize(TypedValue.COMPLEX_UNIT_SP, baseSp * textScale); + return; + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + applyTextScale(group.getChildAt(i)); + } + } } } From 84f7e21bc57736bf2fee1245fa2c2b19106faa10 Mon Sep 17 00:00:00 2001 From: andTDWF <120239807+andTDWF@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:28:01 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refactor(skill):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E6=9D=A5=E6=BA=90=E5=AE=89=E8=A3=85=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 URI、GitHub 和 Skill Hub 安装共用临时文件清理与既有 Skill 安装逻辑,并将二进制文件写入收口到 SkillFileManager。 --- .../data/repository/SkillRepository.java | 52 +++++++++++-------- .../lineai/data/service/SkillFileManager.java | 6 ++- 2 files changed, 36 insertions(+), 22 deletions(-) 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 da753f53..38970f70 100644 --- a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java @@ -15,7 +15,6 @@ import cn.lineai.model.SkillRecord; import cn.lineai.resource.ResourceProvider; import java.io.File; -import java.io.FileOutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -108,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 { @@ -123,11 +123,12 @@ public synchronized SkillRecord installSkillFromGitHub(String homePath, String l fileManager ); File downloaded = installer.downloadToTemp(githubUrl); - try { - return installSkill(homePath, location, downloaded.getAbsolutePath(), downloaded.getName()); - } finally { - fileManager.deleteRecursive(downloaded); - } + return installTemporarySkill( + homePath, + location, + downloaded, + downloaded.getName(), + downloaded); } public synchronized SkillRecord installSkillFromSkillHub( @@ -140,18 +141,27 @@ public synchronized SkillRecord installSkillFromSkillHub( 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"); - tempDir.mkdirs(); + byte[] bytes = new SkillHubClient().download(slug, version); try { - byte[] bytes = new SkillHubClient().download(slug, version); - FileOutputStream output = new FileOutputStream(archive, false); - try { - output.write(bytes); - } finally { - output.close(); - } - return installSkill(homePath, location, archive.getAbsolutePath(), slug); - } finally { + 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, source.getAbsolutePath(), name); + } finally { + fileManager.deleteRecursive(cleanupTarget); } } 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 21715c48..04b3e024 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(); } From 563b19c780b769352519239026a6c6db73429607 Mon Sep 17 00:00:00 2001 From: LangLang03 Date: Tue, 18 Aug 2026 16:20:21 +0800 Subject: [PATCH 3/8] =?UTF-8?q?refactor(skill-hub):=20i18n=20+=20DIP=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skill Hub 相关 UI 组件中全部硬编码中文字符串替换为 R.string 资源 (SkillHubCenterScreenView / LoginScreenView / PublishScreenView / WebScreenView / SkillStoreScreenView / SkillStoreDetailScreenView / SkillIconLoader 等) - Skill Hub Service 层(SkillHubClient / SkillHubSessionClient)注入 ResourceProvider,通过资源文件读取异常消息,不再硬编码中文 - 新增 ContextResourceProvider 作为 Context→ResourceProvider 的桥接, UI 层构造后注入给数据层,避免数据层直接持有 Context(DIP) - 同步修复其他 UI 组件中的遗漏硬编码: ComposerView / SshSettingsScreenView / ExtensionDetailScreenView / ModelListScreenView - 补齐 values/strings.xml 与 values-zh/strings.xml 中 258 条 Skill Hub 相关字符串资源 - 修复 ExtensionManagementControllerTest 因构造函数与 Host 接口 变更导致的编译错误 验证: - ./gradlew :app:compileDebugJavaWithJavac BUILD SUCCESSFUL - ./gradlew :app:testDebugUnitTest (含 SkillHubClientTest、 SkillHubSessionClientTest、ExtensionManagementControllerTest) BUILD SUCCESSFUL --- .../data/repository/SkillRepository.java | 2 +- .../data/service/ContextResourceProvider.java | 39 +++ .../lineai/data/service/SkillHubClient.java | 58 ++-- .../data/service/SkillHubSessionClient.java | 149 ++++++---- .../java/cn/lineai/mvp/MainDependencies.java | 29 +- .../cn/lineai/ui/component/ComposerView.java | 2 +- .../component/ExtensionDetailScreenView.java | 6 +- .../ui/component/ModelListScreenView.java | 3 +- .../ui/component/ScreenScaffoldView.java | 13 + .../component/SkillHubCenterScreenView.java | 69 +++-- .../ui/component/SkillHubLoginScreenView.java | 19 +- .../component/SkillHubPublishScreenView.java | 39 ++- .../ui/component/SkillHubWebScreenView.java | 48 ++-- .../lineai/ui/component/SkillIconLoader.java | 8 +- .../component/SkillStoreDetailScreenView.java | 255 ++++++++++------- .../ui/component/SkillStoreScreenView.java | 95 ++++--- .../ui/component/SshSettingsScreenView.java | 4 +- app/src/main/res/values-zh/strings.xml | 258 ++++++++++++++++++ app/src/main/res/values/strings.xml | 258 ++++++++++++++++++ .../data/service/SkillHubClientTest.java | 41 ++- .../service/SkillHubSessionClientTest.java | 50 +++- .../ExtensionManagementControllerTest.java | 7 + 22 files changed, 1104 insertions(+), 348 deletions(-) create mode 100644 app/src/main/java/cn/lineai/data/service/ContextResourceProvider.java 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 38970f70..f012f3b9 100644 --- a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java @@ -141,7 +141,7 @@ public synchronized SkillRecord installSkillFromSkillHub( 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().download(slug, version); + byte[] bytes = new SkillHubClient(resourceProvider).download(slug, version); try { fileManager.writeBytes(archive, bytes); } catch (RuntimeException error) { 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/SkillHubClient.java b/app/src/main/java/cn/lineai/data/service/SkillHubClient.java index 6da2b81d..03901238 100644 --- a/app/src/main/java/cn/lineai/data/service/SkillHubClient.java +++ b/app/src/main/java/cn/lineai/data/service/SkillHubClient.java @@ -1,6 +1,8 @@ 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; @@ -19,6 +21,12 @@ public final class SkillHubClient { 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); @@ -34,11 +42,12 @@ public SkillHubModels.Page list(int page, int pageSize, String keyword, String c append(url, "order", order); JSONObject root = getJson(url.toString()); if (root.optInt("code", -1) != 0) { - throw new IllegalArgumentException("SkillHub 返回错误: " + root.optString("message")); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_response) + + " " + root.optString("message")); } JSONObject data = root.optJSONObject("data"); if (data == null) { - throw new IllegalArgumentException("SkillHub 列表响应缺少 data"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_missing_data)); } JSONArray values = data.optJSONArray("skills"); ArrayList skills = new ArrayList<>(); @@ -58,7 +67,7 @@ public SkillHubModels.Detail detail(String rawSlug) throws Exception { JSONObject root = getJson(API_ROOT + "/api/v1/skills/" + encode(slug)); JSONObject skill = root.optJSONObject("skill"); if (skill == null) { - throw new IllegalArgumentException("SkillHub 详情响应缺少 skill"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_missing_skill)); } JSONObject latestVersion = root.optJSONObject("latestVersion"); JSONObject namespace = root.optJSONObject("namespace"); @@ -113,7 +122,7 @@ public String fileContent(String rawSlug, String rawVersion, String rawPath) thr CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS); if (value.length() > MAX_MARKDOWN_CHARS) { - throw new IllegalArgumentException("SkillHub 文件内容过大"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_file_too_large)); } return value; } @@ -129,7 +138,7 @@ public byte[] download(String rawSlug, String rawVersion) throws Exception { ); byte[] bytes = result.bytes; if (bytes.length < 4 || bytes[0] != 'P' || bytes[1] != 'K') { - throw new IllegalArgumentException("SkillHub 下载内容不是有效 ZIP"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_zip)); } return bytes; } @@ -139,12 +148,12 @@ public byte[] icon(String rawUrl) throws Exception { SimpleHttpClient.DownloadResult result = SimpleHttpClient.download( url, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS, MAX_ICON_BYTES); if (!result.mimeType.toLowerCase().startsWith("image/")) { - throw new IllegalArgumentException("SkillHub 图标响应不是图片"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_not_image)); } return result.bytes; } - static String requireIconUrl(String rawUrl) { + String requireIconUrl(String rawUrl) { String value = rawUrl == null ? "" : rawUrl.trim(); try { URI uri = new URI(value); @@ -156,13 +165,14 @@ static String requireIconUrl(String rawUrl) { || "api.skillhub.cn".equalsIgnoreCase(host) || "cloudcache.tencent-cloud.com".equalsIgnoreCase(host) || "skillhub-1388575217.cos.accelerate.myqcloud.com".equalsIgnoreCase(host))) { - throw new IllegalArgumentException("无效的 SkillHub 图标地址"); + 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("无效的 SkillHub 图标地址", e); + throw new IllegalArgumentException( + resourceProvider.getString(R.string.skillhub_error_invalid_icon_url), e); } } @@ -194,7 +204,7 @@ private String markdown(String slug, String version, String namespace) throws Ex } String value = SimpleHttpClient.get(url, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS); if (value.length() > MAX_MARKDOWN_CHARS) { - throw new IllegalArgumentException("SkillHub Skill 文档过大"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_doc_too_large)); } return value; } @@ -219,7 +229,7 @@ public List commentReplies( String rawSlug, long commentId, String namespace) throws Exception { String slug = requireSlug(rawSlug); if (commentId <= 0) { - throw new IllegalArgumentException("无效的评论 ID"); + 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)); @@ -336,7 +346,8 @@ private List testCases(String slug, String namespace) t JSONObject value = values.optJSONObject(i); if (value != null) { result.add(new SkillHubModels.TestCase( - "示例 " + (i + 1), value.optString("question"), value.optString("answer"))); + resourceProvider.getString(R.string.skillhub_testcase_label, i + 1), + value.optString("question"), value.optString("answer"))); } } } @@ -351,19 +362,20 @@ private String namespaceQuery(String namespace) { 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("SkillHub 响应过大"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_response_too_large)); } try { return new JSONObject(body); } catch (Exception e) { - throw new IllegalArgumentException("SkillHub 返回了无效 JSON", e); + throw new IllegalArgumentException( + resourceProvider.getString(R.string.skillhub_error_invalid_json), e); } } - static SkillHubModels.Summary parseSummary(JSONObject value) { + SkillHubModels.Summary parseSummary(JSONObject value) { String slug = value.optString("slug").trim(); if (!isSafeSlug(slug)) { - throw new IllegalArgumentException("SkillHub 返回了无效 slug"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_slug)); } JSONObject namespace = value.optJSONObject("namespace"); JSONObject labels = value.optJSONObject("labels"); @@ -435,10 +447,10 @@ private static void append(StringBuilder url, String key, String value) { } } - static String requireSlug(String value) { + String requireSlug(String value) { String slug = value == null ? "" : value.trim(); if (!isSafeSlug(slug)) { - throw new IllegalArgumentException("无效的 SkillHub slug"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_unsafe_slug)); } return slug; } @@ -447,24 +459,24 @@ private static boolean isSafeSlug(String value) { return value != null && value.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); } - static String requireFilePath(String value) { + 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("无效的 SkillHub 文件路径"); + 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("无效的 SkillHub 文件路径"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_path)); } } return path; } - private static String requireVersion(String value) { + 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("无效的 SkillHub 版本"); + throw new IllegalArgumentException(resourceProvider.getString(R.string.skillhub_error_invalid_version)); } return version; } diff --git a/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java b/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java index 759e19d8..12c387e4 100644 --- a/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java +++ b/app/src/main/java/cn/lineai/data/service/SkillHubSessionClient.java @@ -2,8 +2,10 @@ 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; @@ -26,17 +28,27 @@ public final class SkillHubSessionClient { 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("请选择 App 或当前项目中的本地 Skill"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_select_local_skill)); } - String slug = SkillHubClient.requireSlug(rawSlug); - String displayName = requireText(rawDisplayName, "Skill 名称", 100); + String slug = requireSlug(rawSlug); + String displayName = requireText(rawDisplayName); String version = requireVersion(rawVersion); List files = collectPublishFiles(skill); @@ -69,13 +81,13 @@ public Session currentSession() throws Exception { if (response.code == 401) { return Session.signedOut(); } - requireSuccess(response, "获取 SkillHub 账号失败"); + 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 = SkillHubClient.requireSlug(rawSlug); + String slug = requireSlug(rawSlug); String content = requireCommentContent(rawContent); JSONObject body = new JSONObject(); body.put("content", content); @@ -84,13 +96,13 @@ public SkillHubModels.Comment postComment( "POST", "/api/v1/skills/" + encode(slug) + "/comments" + namespaceQuery(namespace), body.toString()); - requireAuthenticatedSuccess(response, "发表评论失败"); + 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 = SkillHubClient.requireSlug(rawSlug); + String slug = requireSlug(rawSlug); requireCommentId(commentId); String content = requireCommentContent(rawContent); JSONObject body = new JSONObject(); @@ -101,49 +113,53 @@ public SkillHubModels.Comment postCommentReply( "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + "/replies" + namespaceQuery(namespace), body.toString()); - requireAuthenticatedSuccess(response, "回复评论失败"); + 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 = SkillHubClient.requireSlug(rawSlug); + 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 ? "点赞评论失败" : "取消点赞失败"); + 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 = SkillHubClient.requireSlug(rawSlug); + String slug = requireSlug(rawSlug); requireCommentId(commentId); SimpleHttpClient.Response response = request( "DELETE", "/api/v1/skills/" + encode(slug) + "/comments/" + commentId + namespaceQuery(namespace)); - requireAuthenticatedSuccess(response, "删除评论失败"); + requireAuthenticatedSuccess(response, resources.getString(R.string.skillhub_error_delete_comment_failed)); } public boolean starred(String rawSlug, String namespace) throws Exception { - String slug = SkillHubClient.requireSlug(rawSlug); + String slug = requireSlug(rawSlug); SimpleHttpClient.Response response = request( "GET", "/api/v1/skills/" + encode(slug) + "/starred" + namespaceQuery(namespace)); - requireAuthenticatedSuccess(response, "获取收藏状态失败"); + 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 = SkillHubClient.requireSlug(rawSlug); + String slug = requireSlug(rawSlug); SimpleHttpClient.Response response = request( starred ? "POST" : "DELETE", "/api/v1/skills/" + encode(slug) + "/star" + namespaceQuery(namespace)); - requireAuthenticatedSuccess(response, starred ? "收藏失败" : "取消收藏失败"); + 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() > 16 * 1024) { - throw new IllegalArgumentException("SkillHub 请求内容过大"); + 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"); } @@ -151,7 +167,7 @@ private SimpleHttpClient.Response jsonRequest(String method, String path, String public void logout() throws Exception { SimpleHttpClient.Response response = request("POST", "/api/v1/auth/logout"); if (response.code != 401) { - requireSuccess(response, "退出 SkillHub 账号失败"); + requireSuccess(response, resources.getString(R.string.skillhub_error_logout_failed)); } clearSkillHubCookies(); } @@ -175,7 +191,8 @@ private SimpleHttpClient.Response execute( } SimpleHttpClient.Response response = SimpleHttpClient.execute(request); if (response.body.length() > MAX_RESPONSE_CHARS) { - throw new IllegalArgumentException("SkillHub 账号响应过大"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_account_response_too_large)); } return response; } @@ -185,17 +202,17 @@ private String sessionCookie() { return requireSafeCookie(cookie); } - static String requireSafeCookie(String rawCookie) { + 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("无效的 SkillHub 会话"); + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_invalid_session)); } return cookie; } - static Account parseAccount(JSONObject root) { + Account parseAccount(JSONObject root) { JSONObject user = root.optJSONObject("user"); if (user == null) { user = root; @@ -210,7 +227,8 @@ static Account parseAccount(JSONObject root) { user.optString("name"), handle); if (displayName.length() == 0 && handle.length() == 0) { - throw new IllegalArgumentException("SkillHub 账号信息不完整"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_incomplete_account)); } return new Account( displayName, @@ -233,7 +251,7 @@ private void clearSkillHubCookies() { } } - private static void collectCookieNames(Map names, String rawCookie) { + private void collectCookieNames(Map names, String rawCookie) { String cookie = requireSafeCookie(rawCookie); if (cookie.length() == 0) { return; @@ -247,30 +265,30 @@ private static void collectCookieNames(Map names, String rawCoo } } - private static void requireAuthenticatedSuccess( + private void requireAuthenticatedSuccess( SimpleHttpClient.Response response, String message) throws Exception { if (response.code == 401) { - throw new IllegalStateException("请先登录 SkillHub 账号"); + throw new IllegalStateException(resources.getString(R.string.skillhub_error_not_logged_in)); } requireSuccess(response, message); } - private static String requireCommentContent(String rawContent) { + private String requireCommentContent(String rawContent) { String content = rawContent == null ? "" : rawContent.trim(); - if (content.length() == 0 || content.codePointCount(0, content.length()) > 500) { - throw new IllegalArgumentException("评论内容应为 1–500 字"); + 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 static void requireCommentId(long commentId) { + private void requireCommentId(long commentId) { if (commentId <= 0) { - throw new IllegalArgumentException("无效的评论 ID"); + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_invalid_comment_id)); } } - private static String publishError(SimpleHttpClient.Response response) { - String fallback = "发布 Skill 失败"; + 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; } @@ -283,26 +301,37 @@ private static String publishError(SimpleHttpClient.Response response) { } } - private static String requireText(String rawValue, String label, int maxLength) { + private String requireText(String rawValue) { String value = rawValue == null ? "" : rawValue.trim(); - if (value.length() == 0 || value.codePointCount(0, value.length()) > maxLength) { - throw new IllegalArgumentException(label + "不能为空且不能超过 " + maxLength + " 字"); + 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 static String requireVersion(String rawVersion) { + 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("无效的 Skill 版本"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_invalid_skill_version)); } return version; } - static List collectPublishFiles(SkillRecord skill) throws Exception { + // 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("本地 Skill 目录不存在"); + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_dir_not_exist)); } ArrayList files = new ArrayList<>(); collectPublishFiles(root, root, files, new long[] {0}); @@ -315,22 +344,23 @@ static List collectPublishFiles(SkillRecord skill) throws Exception } } if (!hasSkillMarkdown) { - throw new IllegalArgumentException("本地 Skill 缺少 SKILL.md"); + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_missing_skill_md)); } return files; } - private static void collectPublishFiles( + private void collectPublishFiles( File root, File current, List files, long[] total) throws Exception { File[] children = current.listFiles(); if (children == null) { - throw new IllegalArgumentException("无法读取本地 Skill 目录"); + 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("Skill 文件路径越界"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_path_out_of_bounds)); } String relative = canonical.getPath().substring(rootPath.length() + 1) .replace(File.separatorChar, '/'); @@ -340,20 +370,24 @@ private static void collectPublishFiles( } if (!canonical.isFile() || isSensitive(relative)) { if (isSensitive(relative)) { - throw new IllegalArgumentException("Skill 包含敏感文件:" + 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("Skill 文件过大:" + relative); + 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("Skill 文件总大小超过 10 MB"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_total_size_exceeded)); } if (files.size() >= MAX_PUBLISH_FILES) { - throw new IllegalArgumentException("Skill 文件数量超过 200 个"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_file_count_exceeded)); } files.add(new PublishFile(relative, readFile(canonical, (int) length))); } @@ -385,7 +419,7 @@ private static byte[] readFile(File file, int expectedLength) throws Exception { return output.toByteArray(); } - private static byte[] multipart( + private byte[] multipart( String boundary, String payload, List files) throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); writePart(output, boundary, "payload", null, "application/json", @@ -396,7 +430,8 @@ private static byte[] multipart( } output.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); if (output.size() > MAX_PUBLISH_TOTAL_BYTES + 512 * 1024) { - throw new IllegalArgumentException("Skill 发布请求过大"); + throw new IllegalArgumentException( + resources.getString(R.string.skillhub_error_publish_request_too_large)); } return output.toByteArray(); } @@ -425,22 +460,22 @@ static final class PublishFile { } } - private static String namespaceQuery(String namespace) { + private String namespaceQuery(String namespace) { String value = namespace == null ? "" : namespace.trim(); return value.length() == 0 ? "" : "?namespace=" + encode(value); } - private static String encode(String value) { + private String encode(String value) { try { return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); } catch (Exception e) { - throw new IllegalArgumentException("无法编码 SkillHub 参数", e); + throw new IllegalArgumentException(resources.getString(R.string.skillhub_error_cannot_encode), e); } } - private static void requireSuccess(SimpleHttpClient.Response response, String message) throws Exception { + private void requireSuccess(SimpleHttpClient.Response response, String message) throws Exception { if (response.code < 200 || response.code >= 300) { - throw new Exception(message + "(HTTP " + response.code + ")"); + throw new Exception(message + resources.getString(R.string.skillhub_error_http_status, response.code)); } } @@ -483,4 +518,4 @@ private Session(boolean authenticated, Account account) { 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/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/ui/component/ComposerView.java b/app/src/main/java/cn/lineai/ui/component/ComposerView.java index 192d425a..94314cac 100644 --- a/app/src/main/java/cn/lineai/ui/component/ComposerView.java +++ b/app/src/main/java/cn/lineai/ui/component/ComposerView.java @@ -790,7 +790,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)); } 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 a3dc8019..80a3c7e4 100644 --- a/app/src/main/java/cn/lineai/ui/component/ExtensionDetailScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ExtensionDetailScreenView.java @@ -84,12 +84,12 @@ 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, "在线商店"); + SettingsSectionView store = new SettingsSectionView(context, getString(R.string.extension_online_store)); store.addRow(new ActionRowView( context, IconButtonView.ARCHIVE, - "SkillHub Skill 商店", - "浏览、搜索并安全安装社区 Skill", + getString(R.string.extension_skillhub_store), + getString(R.string.extension_skillhub_store_desc), false, true, listener::onOpenSkillStore 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..8dc2744c 100644 --- a/app/src/main/java/cn/lineai/ui/component/ModelListScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/ModelListScreenView.java @@ -359,7 +359,8 @@ private void addBottomInset(LinearLayout panel) { 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/ScreenScaffoldView.java b/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java index ff684271..9dcba409 100644 --- a/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java +++ b/app/src/main/java/cn/lineai/ui/component/ScreenScaffoldView.java @@ -38,4 +38,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/SkillHubCenterScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java index c77b1036..97763f95 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java @@ -15,44 +15,63 @@ public interface Listener { } public SkillHubCenterScreenView(Context context, Listener listener) { - super(context, "SkillHub 功能中心", listener::onBack, null); + super(context, context.getString(cn.lineai.R.string.skillhub_center_title), listener::onBack, null); LinearLayout content = getContent(); LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); TextView notice = LineTheme.text(context, - "浏览、安装、评论和收藏使用 LineCode 原生界面;账号、创作者、企业及平台功能在受限 SkillHub 官方页面中完成。", + 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, "账号与社交"); - entry(content, "个人中心", "资料、已发布内容和账号概览", "account", listener); - entry(content, "我的收藏", "查看全部已收藏 Skill", "stars", listener); - entry(content, "我的关注", "查看关注的创作者与动态", "following", listener); - entry(content, "通知中心", "评论、审核和平台通知", "notifications", listener); - entry(content, "账号设置", "头像、绑定、通知与账号安全", "settings", listener); - entry(content, "实名认证", "发布和企业功能所需的官方认证", "verify", listener); - entry(content, "API Token", "创建、查看和撤销平台 Token", "tokens", listener); + 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, "创作者"); - entry(content, "创作者中心", "管理发布、审核状态、版本和申诉", "creator", listener); - entry(content, "官方发布工作台", "图标、GitHub 导入、认领和版本发布", "publish", 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, "发现"); - entry(content, "SkillSet", "浏览 Skill 组合与主题包", "skillsets", listener); - entry(content, "MCP Server", "搜索和查看 MCP Server", "mcp", listener); - entry(content, "Skill Hunt", "榜单、投票与称号", "skill-hunt", listener); - entry(content, "赛事", "赛事作品、排名和获奖信息", "contest", listener); - entry(content, "企业广场", "企业主页、热门 Skill 和关注", "enterprises", 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, "企业与平台"); - entry(content, "企业工作台", "团队 Skill、成员、审核和密钥", "enterprise-dashboard", listener); - entry(content, "企业发布", "发布和维护企业 Skill", "enterprise-publish", listener); - entry(content, "商户管理", "协议、商户状态和开发者密钥", "merchant", listener); - entry(content, "管理后台", "仅对 SkillHub 管理员账号开放", "admin", listener); - entry(content, "Skill 审核", "仅对有审核权限的账号开放", "admin-reviews", 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) { diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java index 5cddfd53..4efc8fdb 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java @@ -16,6 +16,8 @@ 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; @@ -35,7 +37,7 @@ public final class SkillHubLoginScreenView extends LinearLayout { private static final long SESSION_CHECK_INTERVAL_MS = 1000L; - private final SkillHubSessionClient sessionClient = new SkillHubSessionClient(); + private final SkillHubSessionClient sessionClient; private final Handler main = new Handler(Looper.getMainLooper()); private final Runnable onLoginComplete; private final WebView webView; @@ -47,9 +49,10 @@ public final class SkillHubLoginScreenView extends LinearLayout { 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, "SkillHub 官方登录", onBack, null), + 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); @@ -64,7 +67,7 @@ public SkillHubLoginScreenView(Context context, Runnable onBack, Runnable onLogi shield.setFocusable(false); notice.addView(shield, new LayoutParams(LineTheme.dp(context, 28), LineTheme.dp(context, 28))); TextView noticeText = LineTheme.text(context, - "凭据仅提交到 SkillHub 官方页面,LineCode 不读取验证码或密码。", + 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); @@ -74,7 +77,7 @@ public SkillHubLoginScreenView(Context context, Runnable onBack, Runnable onLogi LineTheme.dp(context, LineTheme.LG), 0); addView(notice, noticeParams); - status = LineTheme.text(context, "完成官方登录后将自动返回商店", + 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); @@ -83,7 +86,7 @@ public SkillHubLoginScreenView(Context context, Runnable onBack, Runnable onLogi addView(status, statusParams); webView = new WebView(context); - webView.setContentDescription("SkillHub 官方登录页面"); + webView.setContentDescription(context.getString(R.string.skillhub_login_page_desc)); harden(webView); CookieManager cookieManager = CookieManager.getInstance(); cookieManager.setAcceptCookie(true); @@ -141,9 +144,11 @@ private void checkLogin() { completing = true; main.removeCallbacks(sessionCheck); String name = session.getAccount().getDisplayName(); - status.setText("已登录" + (name.length() == 0 ? "" : ":" + name)); + status.setText(getContext().getString(R.string.skillhub_logged_in) + + (name.length() == 0 ? "" : ":" + name)); status.setTextColor(LineTheme.ACCENT); - Toast.makeText(getContext(), "SkillHub 登录成功", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), getContext().getString(R.string.skillhub_login_success), + Toast.LENGTH_SHORT).show(); main.postDelayed(onLoginComplete, 150); }); } catch (Exception ignored) { diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java index aea77118..7b18008b 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java @@ -12,6 +12,7 @@ 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; @@ -24,7 +25,7 @@ public interface Listener { void onPublished(); } - private final SkillHubSessionClient client = new SkillHubSessionClient(); + private final SkillHubSessionClient client; private final Handler main = new Handler(Looper.getMainLooper()); private final Listener listener; private final List skills = new ArrayList<>(); @@ -38,8 +39,9 @@ public interface Listener { public SkillHubPublishScreenView( Context context, List availableSkills, Listener listener) { - super(context, "发布 Skill", listener::onBack, null); + 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())) { @@ -52,11 +54,14 @@ public SkillHubPublishScreenView( LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); addNotice(content); selected = fieldButton(content); - slug = field(content, "Slug", "例如:my-skill", false); - displayName = field(content, "显示名称", "Skill 名称", false); - version = field(content, "版本", "例如:1.0.0", false); - - publish = LineTheme.textMedium(context, "发布到 SkillHub", + 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); @@ -78,7 +83,7 @@ public SkillHubPublishScreenView( content.addView(progress, progressParams); if (skills.isEmpty()) { - selected.setText("没有可发布的本地 Skill"); + selected.setText(getContext().getString(cn.lineai.R.string.skillhub_no_publishable_skills)); selected.setEnabled(false); publish.setEnabled(false); publish.setAlpha(0.45f); @@ -89,7 +94,7 @@ public SkillHubPublishScreenView( private void addNotice(LinearLayout content) { TextView notice = LineTheme.text(getContext(), - "将上传所选 Skill 目录中的文件。发布前会拒绝私钥、.env、凭据文件及超限内容;SkillHub 的实名认证等规则仍由官方服务校验。", + 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)); @@ -99,7 +104,8 @@ private void addNotice(LinearLayout content) { } private TextView fieldButton(LinearLayout content) { - TextView value = LineTheme.textMedium(getContext(), "选择本地 Skill", + 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); @@ -168,12 +174,12 @@ private void publish() { try { SkillHubSessionClient.Session session = client.currentSession(); if (!session.isAuthenticated()) { - throw new IllegalStateException("请先登录 SkillHub 账号"); + 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(), "Skill 发布成功", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), getContext().getString(cn.lineai.R.string.skillhub_publish_success), Toast.LENGTH_SHORT).show(); listener.onPublished(); }); } catch (Exception e) { @@ -191,12 +197,15 @@ private void setBusy(boolean busy) { displayName.setEnabled(!busy); version.setEnabled(!busy); publish.setEnabled(!busy); - publish.setText(busy ? "正在发布…" : "发布到 SkillHub"); + 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 static String safeMessage(Exception error) { + private String safeMessage(Exception error) { String message = error.getMessage(); - return message == null || message.trim().length() == 0 ? "发布 Skill 失败" : message; + 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 index 5a5085f4..9169d323 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java @@ -32,14 +32,15 @@ public final class SkillHubWebScreenView extends LinearLayout { "workspace.tencent.com", "account.tencent.com" )); - private static final Map DESTINATIONS = destinations(); + private static Map DESTINATIONS; private final WebView webView; @SuppressLint("SetJavaScriptEnabled") public SkillHubWebScreenView(Context context, String destinationId, Runnable onBack) { super(context); - Destination destination = requireDestination(destinationId); + DESTINATIONS = destinations(context); + Destination destination = requireDestination(destinationId, context); setOrientation(VERTICAL); setBackgroundColor(LineTheme.BG); addView(new ScreenHeaderView(context, destination.title, onBack, null), @@ -57,7 +58,7 @@ public SkillHubWebScreenView(Context context, String destinationId, Runnable onB shield.setFocusable(false); notice.addView(shield, new LayoutParams(LineTheme.dp(context, 26), LineTheme.dp(context, 26))); TextView text = LineTheme.text(context, - "此功能由 SkillHub 官方页面提供,会话仅发送到允许的官方域名。", + 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); @@ -110,17 +111,18 @@ private static boolean isAllowed(Uri uri) { && uri.getHost() != null && ALLOWED_HOSTS.contains(uri.getHost().toLowerCase()); } - private static Destination requireDestination(String id) { - Destination destination = DESTINATIONS.get(id); + 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("SkillHub 完整详情", + destination = new Destination(context.getString(cn.lineai.R.string.skillhub_full_detail), "/skills/" + parts[1] + "/" + parts[2]); } } if (destination == null) { - throw new IllegalArgumentException("无效的 SkillHub 功能入口"); + throw new IllegalArgumentException(context.getString(cn.lineai.R.string.skillhub_invalid_entry)); } return destination; } @@ -129,27 +131,27 @@ 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() { + private static Map destinations(Context context) { HashMap values = new HashMap<>(); - add(values, "account", "个人中心", "/dashboard"); - add(values, "settings", "账号设置", "/dashboard/settings"); - add(values, "verify", "实名认证", "/dashboard/verify"); + 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", "我的收藏", "/dashboard/stars"); - add(values, "following", "我的关注", "/dashboard/following"); - add(values, "notifications", "通知中心", "/notifications"); - add(values, "creator", "创作者中心", "/dashboard"); - add(values, "publish", "发布 Skill", "/dashboard/publish"); + 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", "赛事", "/contest"); - add(values, "enterprises", "企业广场", "/enterprise-zone"); - add(values, "enterprise-dashboard", "企业工作台", "/enterprise/dashboard"); - add(values, "enterprise-publish", "企业发布", "/enterprise/dashboard/publish"); - add(values, "merchant", "商户管理", "/admin/merchant"); - add(values, "admin", "管理后台", "/admin"); - add(values, "admin-reviews", "Skill 审核", "/admin/skill-reviews"); + 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); } diff --git a/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java b/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java index b0c1402b..c00f4db7 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillIconLoader.java @@ -1,19 +1,25 @@ 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 = new SkillHubClient(); + 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); diff --git a/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java index dd1d1be4..83e30b99 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java @@ -18,6 +18,8 @@ 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; @@ -50,9 +52,9 @@ public interface Listener { private final String slug; private final Listener listener; - private final SkillHubClient client = new SkillHubClient(); - private final SkillHubSessionClient sessionClient = new SkillHubSessionClient(); - private final SkillIconLoader iconLoader = new SkillIconLoader(); + 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; @@ -63,9 +65,12 @@ public interface Listener { private int activeTab = TAB_OVERVIEW; public SkillStoreDetailScreenView(Context context, String slug, Listener listener) { - super(context, "Skill 详情", listener::onBack, null); + 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, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); progress = new ProgressBar(context); @@ -134,7 +139,7 @@ private void addHero(SkillHubModels.Detail value) { title.setMaxLines(2); titleRow.addView(title, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); if (value.isVerified()) { - TextView verified = tag("已认证", LineTheme.ACCENT, LineTheme.ACCENT_MUTED); + 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); @@ -173,7 +178,7 @@ private void addHero(SkillHubModels.Detail value) { addBadge(badges, "★ " + formatCount(value.getStars()), LineTheme.TEXT_SECONDARY); addBadge(badges, "v" + value.getVersion(), LineTheme.TEXT_SECONDARY); if ("benign".equalsIgnoreCase(value.getSecurityStatus())) { - addBadge(badges, "安全", LineTheme.ACCENT); + addBadge(badges, getString(R.string.skillhub_safe), LineTheme.ACCENT); } LinearLayout.LayoutParams badgeParams = new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); @@ -186,12 +191,14 @@ private void addActions(SkillHubModels.Detail value) { LinearLayout actions = new LinearLayout(getContext()); actions.setOrientation(HORIZONTAL); actions.setGravity(Gravity.CENTER_VERTICAL); - actions.addView(actionButton(IconButtonView.COPY, "复制 Prompt", () -> copyPrompt(value)), + 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) ? "取消收藏" : "收藏"; + 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); @@ -202,11 +209,13 @@ private void addActions(SkillHubModels.Detail value) { LinearLayout.LayoutParams shareParams = new LinearLayout.LayoutParams( 0, LayoutParams.WRAP_CONTENT, 1f); shareParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); - actions.addView(actionButton(IconButtonView.SHARE, "分享", () -> share(value)), shareParams); + 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, "完整功能", + 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); @@ -247,12 +256,12 @@ private void addTabs() { 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, "概述"); - addTab(tabs, TAB_FILES, "文件 · " + detail.getFiles().size()); - addTab(tabs, TAB_COMMENTS, "评论 · " + detail.getComments().size()); - addTab(tabs, TAB_VERSIONS, "版本"); - addTab(tabs, TAB_EVALUATION, "评测报告"); - addTab(tabs, TAB_PREVIEW, "效果预览"); + 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( @@ -306,41 +315,42 @@ private void addActiveTab(SkillHubModels.Detail value) { } private void addOverview(SkillHubModels.Detail value) { - LinearLayout meta = section("Skill 信息", IconButtonView.BOXES); + LinearLayout meta = section(getString(R.string.skillhub_skill_info), IconButtonView.BOXES); LinearLayout primary = new LinearLayout(getContext()); primary.setOrientation(HORIZONTAL); - primary.addView(metadataCard("分类", value.getCategory()), + 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("来源", sourceName(value.getSource())), sourceParams); + 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("版本", "v" + value.getVersion()), + 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("更新", formatDate(value.getUpdatedAt())), dateParams); + secondary.addView(metadataCard(getString(R.string.skillhub_updated), formatDate(value.getUpdatedAt())), dateParams); addSectionContent(meta, secondary); if (!value.getSubCategories().isEmpty()) { - addSectionContent(meta, metadataRow("子分类", join(value.getSubCategories()))); + addSectionContent(meta, metadataRow(getString(R.string.skillhub_subcategories), join(value.getSubCategories()))); } if (!value.getTags().isEmpty()) { - addSectionContent(meta, metadataRow("标签", join(value.getTags()))); + addSectionContent(meta, metadataRow(getString(R.string.skillhub_tags), join(value.getTags()))); } addSection(meta); addSecurity(value); - LinearLayout content = section("SKILL.md", IconButtonView.FILE_TEXT); - TextView zoomHint = LineTheme.text(getContext(), "双指缩放可调整文档字号", + 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("该版本暂无公开 Skill 文档")); + addSectionContent(content, emptyText(getString(R.string.skillhub_no_public_doc))); } else { LinearLayout reader = new LinearLayout(getContext()); reader.setOrientation(VERTICAL); @@ -362,16 +372,17 @@ private void addSecurity(SkillHubModels.Detail value) { boolean unsafe = value.hasScripts() || (!value.getSecurityStatus().isEmpty() && !"benign".equalsIgnoreCase(value.getSecurityStatus())); - LinearLayout section = section("安全检查", + 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 ? "需要在使用前检查 Skill 内容" : "未发现明确的安全风险"; + : unsafe ? getString(R.string.skillhub_check_before_use) + : getString(R.string.skillhub_no_obvious_risk); if (value.hasScripts()) { - text += "\n包含 scripts/ 或脚本文件;安装只会落盘,不会自动执行。"; + text += "\n" + getString(R.string.skillhub_contains_scripts); } if (value.requiresApiKey()) { - text += "\n可能需要自行配置 API Key,请勿将密钥写入 Skill 文件。"; + 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, @@ -380,12 +391,13 @@ private void addSecurity(SkillHubModels.Detail value) { } private void addFiles(SkillHubModels.Detail value) { - LinearLayout section = section("文件清单 · " + value.getFiles().size() + " 个", + LinearLayout section = section(getString(R.string.skillhub_file_list, value.getFiles().size()), IconButtonView.FILE_TEXT); - addSectionContent(section, LineTheme.text(getContext(), "点击文件可预览公开文本内容", + 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("该版本没有公开文件")); + addSectionContent(section, emptyText(getString(R.string.skillhub_no_public_files))); } else { for (SkillHubModels.FileEntry file : value.getFiles()) { addSectionContent(section, fileRow(value, file)); @@ -400,7 +412,7 @@ private View fileRow(SkillHubModels.Detail value, SkillHubModels.FileEntry file) row.setGravity(Gravity.CENTER_VERTICAL); row.setClickable(true); row.setFocusable(true); - row.setContentDescription("预览 " + file.getPath()); + 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); @@ -421,7 +433,7 @@ private View fileRow(SkillHubModels.Detail value, SkillHubModels.FileEntry file) String path = file.getPath(); int slash = path.lastIndexOf('/'); String name = slash < 0 ? path : path.substring(slash + 1); - String directory = slash < 0 ? "根目录" : path.substring(0, slash); + 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); @@ -456,11 +468,11 @@ private int fileIcon(String path) { private void previewFile(SkillHubModels.Detail value, SkillHubModels.FileEntry file, View row) { if (!isPreviewable(file.getPath())) { - Toast.makeText(getContext(), "该文件类型不支持文本预览", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), getString(R.string.skillhub_file_not_supported), Toast.LENGTH_SHORT).show(); return; } row.setEnabled(false); - Toast.makeText(getContext(), "正在加载文件…", Toast.LENGTH_SHORT).show(); + 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()); @@ -493,7 +505,7 @@ private void showFilePreview(String path, String content) { 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(), "双指缩放可调整文档字号", + 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); @@ -514,17 +526,20 @@ private void showFilePreview(String path, String content) { new AlertDialog.Builder(getContext()) .setTitle(path) .setView(host) - .setNegativeButton("关闭", null) - .setPositiveButton("复制", (dialog, which) -> { + .setNegativeButton(getString(R.string.skillhub_close), null) + .setPositiveButton(getString(R.string.skillhub_copy), (dialog, which) -> { ShareHelper.copy(getContext(), content); - Toast.makeText(getContext(), "文件内容已复制", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), + getString(R.string.skillhub_file_copied), Toast.LENGTH_SHORT).show(); }) .show(); } private void addComments(SkillHubModels.Detail value) { - LinearLayout section = section("社区评论", IconButtonView.MESSAGE_CIRCLE); - TextView compose = LineTheme.textMedium(getContext(), "发表评论", + 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); @@ -534,7 +549,7 @@ private void addComments(SkillHubModels.Detail value) { compose.setOnClickListener(v -> checkSessionForComment(value, null)); addSectionContent(section, compose); if (value.getComments().isEmpty()) { - addSectionContent(section, emptyText("暂无公开评论,登录后可发表第一条评论")); + addSectionContent(section, emptyText(getString(R.string.skillhub_no_comments_login_first))); } else { for (SkillHubModels.Comment comment : value.getComments()) { addComment(section, comment, 0); @@ -552,7 +567,9 @@ private void checkSessionForComment( if (session.isAuthenticated()) { showCommentDialog(value, parent); } else { - Toast.makeText(getContext(), "请先登录 SkillHub 账号", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), + getString(R.string.skillhub_login_to_star), + Toast.LENGTH_SHORT).show(); listener.onLogin(); } }); @@ -570,11 +587,14 @@ private void showCommentDialog( 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 ? "发表评论" : "回复 " - + (parent.getAuthor().length() == 0 ? "SkillHub 用户" : parent.getAuthor()); + 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(), "评论提交后需经过 SkillHub 审核,最多 500 字。", + 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); @@ -582,7 +602,8 @@ private void showCommentDialog( panel.addView(hint, hintParams); EditText input = new EditText(getContext()); - input.setHint(parent == null ? "分享你的使用体验…" : "写下回复…"); + 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); @@ -601,8 +622,8 @@ private void showCommentDialog( LinearLayout actions = new LinearLayout(getContext()); actions.setOrientation(HORIZONTAL); - TextView cancel = dialogButton("取消", false); - TextView submit = dialogButton("提交评论", true); + 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); @@ -618,13 +639,14 @@ private void showCommentDialog( submit.setOnClickListener(v -> { String content = input.getText().toString().trim(); if (content.length() == 0 || content.codePointCount(0, content.length()) > 500) { - Toast.makeText(getContext(), "评论内容应为 1–500 字", Toast.LENGTH_SHORT).show(); + 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("正在提交…"); + submit.setText(getString(R.string.skillhub_submitting)); new Thread(() -> { try { if (parent == null) { @@ -637,7 +659,8 @@ private void showCommentDialog( main.post(() -> { dialog.dismiss(); activeTab = TAB_COMMENTS; - Toast.makeText(getContext(), "评论已提交,审核通过后展示", + Toast.makeText(getContext(), + getString(R.string.skillhub_comment_submitted), Toast.LENGTH_LONG).show(); load(); }); @@ -646,7 +669,7 @@ private void showCommentDialog( submit.setEnabled(true); cancel.setEnabled(true); input.setEnabled(true); - submit.setText("提交评论"); + submit.setText(getString(R.string.skillhub_submit_comment)); Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); }); } @@ -661,7 +684,8 @@ private void addComment(LinearLayout section, SkillHubModels.Comment comment, in 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 ? "SkillHub 用户" : comment.getAuthor()) + 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)); @@ -675,19 +699,21 @@ private void addComment(LinearLayout section, SkillHubModels.Comment comment, in LinearLayout actions = new LinearLayout(getContext()); actions.setOrientation(HORIZONTAL); TextView like = commentAction( - (comment.isLiked() ? "取消赞" : "赞") + (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("回复"); + 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("全部回复 " + comment.getReplyCount()); + TextView allReplies = commentAction( + getString(R.string.skillhub_all_replies, comment.getReplyCount())); allReplies.setOnClickListener(v -> loadCommentReplies(section, comment, allReplies)); actions.addView(allReplies); } - TextView delete = commentAction("删除"); + TextView delete = commentAction(getString(R.string.skillhub_delete)); delete.setTextColor(LineTheme.DANGER); delete.setOnClickListener(v -> deleteComment(comment, delete)); actions.addView(delete); @@ -785,9 +811,10 @@ private void loadCommentReplies( } private void addVersions(SkillHubModels.Detail value) { - LinearLayout section = section("版本历史", IconButtonView.CLOCK_3); + LinearLayout section = section(getString(R.string.skillhub_version_history), + IconButtonView.CLOCK_3); if (value.getVersions().isEmpty()) { - addSectionContent(section, emptyText("暂无版本历史")); + 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()); @@ -805,14 +832,15 @@ private void addVersions(SkillHubModels.Detail value) { } private void addEvaluation(SkillHubModels.Detail value) { - LinearLayout section = section("SkillHub 评测报告", IconButtonView.FLASK_CONICAL); + 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("该 Skill 暂无公开评测报告")); + addSectionContent(section, emptyText(getString(R.string.skillhub_no_evaluation))); } else { if (evaluation.getScore() > 0) { addSectionContent(section, LineTheme.textMedium(getContext(), - String.format(Locale.getDefault(), "综合评分 %.1f", evaluation.getScore()), + getString(R.string.skillhub_overall_score, evaluation.getScore()), LineTheme.FONT_XL, LineTheme.ACCENT)); } addSectionContent(section, LineTheme.text(getContext(), evaluation.getSummary(), @@ -828,13 +856,13 @@ private void addEvaluation(SkillHubModels.Detail value) { } private void addPreview(SkillHubModels.Detail value) { - LinearLayout section = section("效果预览", IconButtonView.PLAY); + LinearLayout section = section(getString(R.string.skillhub_tab_preview), IconButtonView.PLAY); if (value.getTestCases().isEmpty()) { - addSectionContent(section, emptyText("该 Skill 暂无公开使用示例")); + addSectionContent(section, emptyText(getString(R.string.skillhub_no_preview))); } else { for (SkillHubModels.TestCase testCase : value.getTestCases()) { TextView prompt = LineTheme.textMedium(getContext(), - testCase.getTitle() + "\n用户:" + testCase.getPrompt(), + 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); @@ -857,7 +885,7 @@ private View metadataCard(String label, String value) { card.addView(LineTheme.text(getContext(), label, LineTheme.FONT_XS, LineTheme.TEXT_TERTIARY, Typeface.NORMAL)); TextView content = LineTheme.textMedium(getContext(), - value.length() == 0 ? "—" : value, LineTheme.FONT_SM, LineTheme.TEXT); + 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); @@ -914,12 +942,13 @@ private void addSection(LinearLayout section) { } private void addInstallButton(SkillHubModels.Detail value) { - TextView install = LineTheme.textMedium(getContext(), "选择位置并安装", + 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("安装 " + value.getName()); + 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)); @@ -988,7 +1017,9 @@ private void toggleStar(SkillHubModels.Detail value) { main.post(() -> { starBusy = false; updateStarButton(); - Toast.makeText(getContext(), "请先登录 SkillHub 账号", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), + getString(R.string.skillhub_login_to_star), + Toast.LENGTH_SHORT).show(); listener.onLogin(); }); return; @@ -1003,7 +1034,9 @@ private void toggleStar(SkillHubModels.Detail value) { starBusy = false; updateStarButton(); Toast.makeText(getContext(), - next ? "收藏成功" : "已取消收藏", Toast.LENGTH_SHORT).show(); + next ? getString(R.string.skillhub_star_success) + : getString(R.string.skillhub_unstar_success), + Toast.LENGTH_SHORT).show(); }); } catch (Exception e) { main.post(() -> { @@ -1024,15 +1057,17 @@ private void updateStarButton() { LinearLayout button = (LinearLayout) starButton; if (button.getChildCount() > 1 && button.getChildAt(1) instanceof TextView) { ((TextView) button.getChildAt(1)).setText( - Boolean.TRUE.equals(starred) ? "取消收藏" : "收藏"); + Boolean.TRUE.equals(starred) ? getString(R.string.skillhub_unstar) + : getString(R.string.skillhub_star)); } starButton.setContentDescription( - Boolean.TRUE.equals(starred) ? "取消收藏" : "收藏"); + 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(), "安装 Prompt 已复制", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), getString(R.string.skillhub_prompt_copied), Toast.LENGTH_SHORT).show(); } private void share(SkillHubModels.Detail value) { @@ -1041,21 +1076,22 @@ private void share(SkillHubModels.Detail value) { } private String installPrompt(SkillHubModels.Detail value) { - return "请从 SkillHub 安装 Skill:" + value.getCanonicalName() - + ",版本 " + value.getVersion() - + "。安装前请检查文件与安全报告,不要自动执行其中的脚本。"; + 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(), "仅允许打开 HTTPS 链接", Toast.LENGTH_SHORT).show(); + 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(), "无法打开链接", Toast.LENGTH_SHORT).show(); + Toast.makeText(getContext(), getString(R.string.toast_open_link_failed, + rawUrl == null ? "" : rawUrl), Toast.LENGTH_SHORT).show(); } } @@ -1084,11 +1120,13 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut 0, LayoutParams.WRAP_CONTENT, 1f); titleCopyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.MD); heading.addView(titleCopy, titleCopyParams); - TextView title = LineTheme.textMedium(getContext(), "安装 " + value.getName(), + 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(), "选择 Skill 的安装范围", + 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); @@ -1100,13 +1138,15 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut facts.setOrientation(HORIZONTAL); facts.addView(tag("SkillHub", LineTheme.TEXT_SECONDARY, LineTheme.SURFACE_LIGHT)); addBadge(facts, "v" + value.getVersion(), LineTheme.TEXT_SECONDARY); - addBadge(facts, value.getFiles().size() + " 个文件", 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(), "安装位置", + 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); @@ -1114,9 +1154,11 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut panel.addView(locationLabel, locationLabelParams); LinearLayout appOption = installLocationOption( - IconButtonView.SMARTPHONE, "App 全局 Skills", "所有工作区均可使用", true); + IconButtonView.SMARTPHONE, getString(R.string.skillhub_location_app_title), + getString(R.string.skillhub_location_app_desc), true); LinearLayout projectOption = installLocationOption( - IconButtonView.FOLDER, "当前项目", ".linecode/skills · 仅当前工作区", false); + 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); @@ -1138,10 +1180,10 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut if (value.hasScripts() || value.requiresApiKey()) { String warning = value.hasScripts() - ? "包含脚本文件。安装只会落盘,不会自动执行;使用前请检查内容。" - : "该 Skill 可能需要 API Key,请勿将密钥写入 Skill 文件。"; + ? getString(R.string.skillhub_contains_scripts_confirm) + : getString(R.string.skillhub_may_require_api_key_confirm); if (value.hasScripts() && value.requiresApiKey()) { - warning += "\n可能还需要自行配置 API Key。"; + warning += "\n" + getString(R.string.skillhub_may_also_require_api_key); } LinearLayout warningCard = new LinearLayout(getContext()); warningCard.setOrientation(HORIZONTAL); @@ -1169,8 +1211,8 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut LinearLayout actions = new LinearLayout(getContext()); actions.setOrientation(HORIZONTAL); - TextView cancel = dialogButton("取消", false); - TextView install = dialogButton("安装", true); + 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); @@ -1191,15 +1233,16 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut appOption.setEnabled(false); projectOption.setEnabled(false); installButton.setEnabled(false); - install.setText("正在安装…"); - installButton.setText("正在安装…"); + 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("已安装"); - Toast.makeText(getContext(), "Skill 安装成功", + installButton.setText(getString(R.string.skillhub_installed)); + Toast.makeText(getContext(), + getString(R.string.skillhub_install_success), Toast.LENGTH_LONG).show(); }); } catch (Exception e) { @@ -1209,8 +1252,8 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut appOption.setEnabled(true); projectOption.setEnabled(true); installButton.setEnabled(true); - install.setText("安装"); - installButton.setText("选择位置并安装"); + 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(); }); @@ -1285,8 +1328,10 @@ private TextView dialogButton(String value, boolean primary) { private void renderError(Exception error) { body.removeAllViews(); - LinearLayout errorCard = section("详情加载失败", IconButtonView.CIRCLE_ALERT); - TextView message = LineTheme.text(getContext(), safeMessage(error) + "\n点此重试", + 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); @@ -1326,7 +1371,8 @@ private String namespaceHandle(SkillHubModels.Detail value) { } private String sourceName(String source) { - return "community".equalsIgnoreCase(source) ? "SkillHub 社区" : source; + return "community".equalsIgnoreCase(source) + ? getString(R.string.skillhub_source_community) : source; } private String join(List values) { @@ -1342,7 +1388,7 @@ private String join(List values) { private String formatCount(long value) { if (value >= 10000) { - return String.format(Locale.getDefault(), "%.1f万", value / 10000d); + return getString(R.string.skillhub_count_wan, value / 10000d); } return String.valueOf(value); } @@ -1359,13 +1405,14 @@ private String formatBytes(long value) { private String formatDate(long value) { if (value <= 0) { - return "—"; + 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 ? "未知错误" : error.getMessage(); + 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 index f8264838..603aadcd 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java @@ -14,6 +14,8 @@ 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; @@ -21,7 +23,6 @@ import cn.lineai.ui.theme.LineTheme; import java.util.ArrayList; import java.util.List; -import java.util.Locale; public final class SkillStoreScreenView extends ScreenScaffoldView { public interface Listener { @@ -33,9 +34,9 @@ public interface Listener { } private final Listener listener; - private final SkillHubClient client = new SkillHubClient(); - private final SkillHubSessionClient sessionClient = new SkillHubSessionClient(); - private final SkillIconLoader iconLoader = new SkillIconLoader(); + 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; @@ -54,8 +55,11 @@ public interface Listener { private String sortBy = "downloads"; public SkillStoreScreenView(Context context, Listener listener) { - super(context, "Skill 商店", listener::onBack, null); + super(context, context.getString(R.string.skillhub_title_store), listener::onBack, null); 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, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); @@ -102,10 +106,11 @@ private void addIntro(LinearLayout content) { copy.setOrientation(VERTICAL); header.addView(copy, new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - copy.addView(LineTheme.textMedium(getContext(), "发现社区 Skills", + copy.addView(LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_discover_community_skills), LineTheme.FONT_XL, LineTheme.TEXT)); TextView description = LineTheme.text(getContext(), - "浏览、检查并安装来自 SkillHub 的社区能力", + 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); @@ -141,7 +146,7 @@ private EditText addSearch(LinearLayout content) { EditText field = new EditText(getContext()); field.setSingleLine(true); - field.setHint("搜索名称、描述或作者"); + field.setHint(getContext().getString(R.string.skillhub_search_hint)); field.setHintTextColor(LineTheme.TEXT_TERTIARY); field.setTextColor(LineTheme.TEXT); field.setTextSize(LineTheme.FONT_MD); @@ -174,9 +179,9 @@ private void addFilters(LinearLayout content) { LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); params.topMargin = LineTheme.dp(getContext(), LineTheme.SM); content.addView(filters, params); - addFilter(filters, "热门下载", "downloads"); - addFilter(filters, "最多收藏", "stars"); - addFilter(filters, "最近更新", "updated_at"); + 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) { @@ -185,7 +190,7 @@ private void addFilter(LinearLayout host, String label, String value) { chip.setGravity(Gravity.CENTER); chip.setClickable(true); chip.setFocusable(true); - chip.setContentDescription("按" + label + "排序"); + 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)) { @@ -224,7 +229,7 @@ private void addLogin(LinearLayout content) { accountRow.setGravity(Gravity.CENTER_VERTICAL); accountRow.setClickable(true); accountRow.setFocusable(true); - accountRow.setContentDescription("SkillHub 账号"); + accountRow.setContentDescription(getContext().getString(R.string.skillhub_account_label)); accountRow.setOnClickListener(v -> { if (accountSession != null && accountSession.isAuthenticated()) { showAccountDialog(accountSession.getAccount()); @@ -250,10 +255,12 @@ private void addLogin(LinearLayout content) { 0, LayoutParams.WRAP_CONTENT, 1f); copyParams.leftMargin = LineTheme.dp(getContext(), LineTheme.SM); accountRow.addView(copy, copyParams); - accountTitle = LineTheme.textMedium(getContext(), "正在检查 SkillHub 账号…", + accountTitle = LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_checking_account), LineTheme.FONT_SM, LineTheme.TEXT); copy.addView(accountTitle); - accountSubtitle = LineTheme.text(getContext(), "登录由 SkillHub 官方页面处理", + 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); @@ -275,7 +282,8 @@ private void addLogin(LinearLayout content) { } private void addPublish(LinearLayout content) { - publishButton = LineTheme.textMedium(getContext(), "SkillHub 功能中心", + publishButton = LineTheme.textMedium(getContext(), + getContext().getString(R.string.skillhub_feature_center), LineTheme.FONT_SM, LineTheme.ACCENT); publishButton.setGravity(Gravity.CENTER); publishButton.setClickable(true); @@ -294,8 +302,8 @@ private void addPublish(LinearLayout content) { private void loadAccount() { accountRow.setEnabled(false); - accountTitle.setText("正在检查 SkillHub 账号…"); - accountSubtitle.setText("登录由 SkillHub 官方页面处理"); + 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(); @@ -305,8 +313,9 @@ private void loadAccount() { accountSession = null; publishButton.setVisibility(GONE); accountRow.setEnabled(true); - accountTitle.setText("账号状态检查失败"); - accountSubtitle.setText("点此重试 · " + safeMessage(e)); + 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()); }); @@ -326,8 +335,8 @@ private void renderAccount(SkillHubSessionClient.Session session) { }); if (!session.isAuthenticated()) { publishButton.setVisibility(GONE); - accountTitle.setText("登录 SkillHub 账号"); - accountSubtitle.setText("登录后可发布、评论和收藏 Skill"); + accountTitle.setText(getContext().getString(R.string.skillhub_login_account)); + accountSubtitle.setText(getContext().getString(R.string.skillhub_login_desc)); accountAction.setIconType(IconButtonView.EXTERNAL_LINK); return; } @@ -335,7 +344,9 @@ private void renderAccount(SkillHubSessionClient.Session session) { SkillHubSessionClient.Account account = session.getAccount(); accountTitle.setText(account.getDisplayName()); accountSubtitle.setText(account.getHandle().length() == 0 - ? "SkillHub 已登录" : "@" + account.getHandle() + " · 已登录"); + ? getContext().getString(R.string.skillhub_logged_in) + : "@" + account.getHandle() + " · " + + getContext().getString(R.string.skillhub_logged_in_suffix)); accountAction.setIconType(IconButtonView.CHEVRON_RIGHT); } @@ -371,7 +382,8 @@ private void showAccountDialog(SkillHubSessionClient.Account account) { panel.addView(handle); } - TextView status = LineTheme.textMedium(getContext(), "SkillHub 账号已连接", + 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)); @@ -383,8 +395,10 @@ private void showAccountDialog(SkillHubSessionClient.Account account) { LinearLayout actions = new LinearLayout(getContext()); actions.setOrientation(HORIZONTAL); - TextView close = accountDialogButton("继续使用", false); - TextView logout = accountDialogButton("退出登录", true); + 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); @@ -400,21 +414,21 @@ private void showAccountDialog(SkillHubSessionClient.Account account) { logout.setOnClickListener(v -> { close.setEnabled(false); logout.setEnabled(false); - logout.setText("正在退出…"); + 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(), "已退出 SkillHub 账号", + 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("退出登录"); + logout.setText(getContext().getString(R.string.skillhub_logout)); Toast.makeText(getContext(), safeMessage(e), Toast.LENGTH_LONG).show(); }); } @@ -439,7 +453,7 @@ private TextView accountDialogButton(String value, boolean danger) { private void addPager(LinearLayout content) { LinearLayout pager = new LinearLayout(getContext()); pager.setGravity(Gravity.CENTER); - TextView previous = pagerButton("上一页"); + TextView previous = pagerButton(getContext().getString(R.string.skillhub_previous_page)); previous.setOnClickListener(v -> { if (page > 1) { page--; @@ -447,7 +461,7 @@ private void addPager(LinearLayout content) { } }); pager.addView(previous); - TextView next = pagerButton("下一页"); + TextView next = pagerButton(getContext().getString(R.string.skillhub_next_page)); next.setOnClickListener(v -> { page++; load(); @@ -480,7 +494,7 @@ private void load() { final String keyword = search.getText().toString(); final String requestedSort = sortBy; progress.setVisibility(VISIBLE); - status.setText("正在加载第 " + requestedPage + " 页…"); + status.setText(getContext().getString(R.string.skillhub_loading_page, requestedPage)); results.removeAllViews(); new Thread(() -> { try { @@ -495,7 +509,7 @@ private void load() { main.post(() -> { if (generation != requestGeneration) return; progress.setVisibility(GONE); - status.setText("加载失败,点此重试\n" + safeMessage(e)); + status.setText(getContext().getString(R.string.skillhub_load_failed_retry) + "\n" + safeMessage(e)); status.setOnClickListener(v -> load()); }); } @@ -505,8 +519,9 @@ private void load() { private void render(SkillHubModels.Page value, int requestedPage) { status.setOnClickListener(null); status.setText(value.getSkills().isEmpty() - ? "没有找到匹配的 Skill" - : "第 " + requestedPage + " 页 · 共 " + formatCount(value.getTotal()) + " 个 Skill"); + ? 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)); } @@ -518,7 +533,7 @@ private View card(SkillHubModels.Summary skill) { card.setGravity(Gravity.CENTER_VERTICAL); card.setClickable(true); card.setFocusable(true); - card.setContentDescription(skill.getName() + ",查看详情"); + card.setContentDescription(skill.getName() + getContext().getString(R.string.skillhub_view_details)); card.setOnClickListener(v -> listener.onOpen(skill.getSlug())); card.setBackground(LineTheme.roundedStroke( getContext(), LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); @@ -555,7 +570,7 @@ private View card(SkillHubModels.Summary skill) { titleRow.addView(title, new LinearLayout.LayoutParams( 0, LayoutParams.WRAP_CONTENT, 1f)); if (skill.isVerified()) { - titleRow.addView(tag("已认证", LineTheme.ACCENT, LineTheme.ACCENT_MUTED)); + titleRow.addView(tag(getContext().getString(R.string.skillhub_verified), LineTheme.ACCENT, LineTheme.ACCENT_MUTED)); } copy.addView(titleRow); @@ -577,7 +592,7 @@ private View card(SkillHubModels.Summary skill) { tags.addView(tag(skill.getCategory(), LineTheme.TEXT_SECONDARY, LineTheme.SURFACE_LIGHT)); } if (skill.requiresApiKey()) { - TextView apiKey = tag("需要 API Key", LineTheme.WARNING, LineTheme.SURFACE_LIGHT); + 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); @@ -623,13 +638,13 @@ private TextView tag(String value, int color, int background) { private String formatCount(long value) { if (value >= 10000) { - return String.format(Locale.getDefault(), "%.1f万", value / 10000d); + return getContext().getString(R.string.skillhub_count_wan, value / 10000d); } return String.valueOf(value); } private String safeMessage(Exception e) { - return e.getMessage() == null ? "未知错误" : e.getMessage(); + return e.getMessage() == null ? getContext().getString(R.string.skillhub_unknown_error) : e.getMessage(); } private static final class FilterChip { 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..f7f33c64 100644 --- a/app/src/main/java/cn/lineai/ui/component/SshSettingsScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SshSettingsScreenView.java @@ -163,14 +163,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) { diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 5c8ff546..a9db16b1 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1210,4 +1210,262 @@ 以下为模拟数据,状态不代表真实工具运行结果。 查看教程 从零到进阶:模型配置、工作区、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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af1a60f4..01066504 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1209,4 +1209,262 @@ 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 diff --git a/app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java b/app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java index 11f3d98e..46395e0e 100644 --- a/app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java +++ b/app/src/test/java/cn/lineai/data/service/SkillHubClientTest.java @@ -5,11 +5,30 @@ import static org.junit.Assert.assertTrue; import cn.lineai.model.SkillHubModels; +import cn.lineai.resource.ResourceProvider; import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; public final class SkillHubClientTest { + private static final ResourceProvider RESOURCES = new ResourceProvider() { + @Override + public java.io.InputStream openAsset(String path) { + throw new UnsupportedOperationException("not needed in unit tests"); + } + + @Override + public String getString(int resId) { + return "error"; + } + + @Override + public String getString(int resId, Object... formatArgs) { + return "error"; + } + }; + + private static final SkillHubClient CLIENT = new SkillHubClient(RESOURCES); @Test public void parseSummaryPrefersChineseDescriptionAndMapsRiskFields() throws Exception { JSONObject value = new JSONObject() @@ -30,7 +49,7 @@ public void parseSummaryPrefersChineseDescriptionAndMapsRiskFields() throws Exce .put("subCategories", new JSONArray() .put(new JSONObject().put("key", "pdf").put("name", "PDF 工具"))); - SkillHubModels.Summary summary = SkillHubClient.parseSummary(value); + SkillHubModels.Summary summary = CLIENT.parseSummary(value); assertEquals("pdf-helper", summary.getSlug()); assertEquals("中文说明", summary.getDescription()); @@ -42,20 +61,20 @@ public void parseSummaryPrefersChineseDescriptionAndMapsRiskFields() throws Exce @Test(expected = IllegalArgumentException.class) public void parseSummaryRejectsUnsafeSlug() throws Exception { - SkillHubClient.parseSummary(new JSONObject().put("slug", "../escape")); + CLIENT.parseSummary(new JSONObject().put("slug", "../escape")); } @Test public void iconUrlAllowsOnlySkillHubHttpsHosts() { assertEquals( "https://api.skillhub.cn/icons/skill.png", - SkillHubClient.requireIconUrl("https://api.skillhub.cn/icons/skill.png")); + CLIENT.requireIconUrl("https://api.skillhub.cn/icons/skill.png")); assertEquals( "https://cloudcache.tencent-cloud.com/qcloud/ui/icon.png", - SkillHubClient.requireIconUrl("https://cloudcache.tencent-cloud.com/qcloud/ui/icon.png")); + CLIENT.requireIconUrl("https://cloudcache.tencent-cloud.com/qcloud/ui/icon.png")); assertEquals( "https://skillhub-1388575217.cos.accelerate.myqcloud.com/skill-icons/icon.png", - SkillHubClient.requireIconUrl( + CLIENT.requireIconUrl( "https://skillhub-1388575217.cos.accelerate.myqcloud.com/skill-icons/icon.png")); assertRejectedIconUrl("http://skillhub.cn/icons/skill.png"); assertRejectedIconUrl("https://skillhub.cn.evil.example/icons/skill.png"); @@ -65,23 +84,23 @@ public void iconUrlAllowsOnlySkillHubHttpsHosts() { @Test public void filePathAllowsNestedRelativePaths() { - assertEquals("references/api.md", SkillHubClient.requireFilePath("references/api.md")); - assertEquals("SKILL.md", SkillHubClient.requireFilePath("SKILL.md")); + assertEquals("references/api.md", CLIENT.requireFilePath("references/api.md")); + assertEquals("SKILL.md", CLIENT.requireFilePath("SKILL.md")); } @Test(expected = IllegalArgumentException.class) public void filePathRejectsTraversal() { - SkillHubClient.requireFilePath("references/../SKILL.md"); + CLIENT.requireFilePath("references/../SKILL.md"); } @Test(expected = IllegalArgumentException.class) public void filePathRejectsAbsolutePath() { - SkillHubClient.requireFilePath("/SKILL.md"); + CLIENT.requireFilePath("/SKILL.md"); } @Test(expected = IllegalArgumentException.class) public void filePathRejectsBackslashes() { - SkillHubClient.requireFilePath("references\\api.md"); + CLIENT.requireFilePath("references\\api.md"); } @Test public void parseCommentMapsPublicReplies() throws Exception { @@ -109,7 +128,7 @@ public void parseCommentMapsPublicReplies() throws Exception { private static void assertRejectedIconUrl(String value) { try { - SkillHubClient.requireIconUrl(value); + CLIENT.requireIconUrl(value); } catch (IllegalArgumentException expected) { return; } diff --git a/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java b/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java index 604b79f9..99d4a992 100644 --- a/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java +++ b/app/src/test/java/cn/lineai/data/service/SkillHubSessionClientTest.java @@ -4,14 +4,52 @@ import static org.junit.Assert.assertTrue; import cn.lineai.model.SkillRecord; +import cn.lineai.resource.ResourceProvider; import java.io.File; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.List; +import java.util.Locale; import org.json.JSONObject; import org.junit.Test; public final class SkillHubSessionClientTest { + /** + * Fake resource provider used by the JVM unit tests (no Android resources + * available). It maps the handful of resource ids exercised by these tests + * back to the Chinese texts asserted below. + */ + private static final ResourceProvider RES = new ResourceProvider() { + @Override + public InputStream openAsset(String path) { + throw new UnsupportedOperationException("openAsset not used in tests"); + } + + @Override + public String getString(int resId) { + if (resId == cn.lineai.R.string.skillhub_error_sensitive_file) { + return "Skill 包含敏感文件:"; + } + if (resId == cn.lineai.R.string.skillhub_error_file_too_large_with_name) { + return "Skill 文件过大:"; + } + if (resId == cn.lineai.R.string.skillhub_error_invalid_session) { + return "无效的 SkillHub 会话"; + } + return "test"; + } + + @Override + public String getString(int resId, Object... formatArgs) { + return String.format(Locale.ROOT, getString(resId), formatArgs); + } + }; + + private static SkillHubSessionClient client() { + return new SkillHubSessionClient(RES); + } + @Test public void parsesNestedUserAccount() throws Exception { JSONObject root = new JSONObject().put("user", new JSONObject() @@ -19,7 +57,7 @@ public void parsesNestedUserAccount() throws Exception { .put("handle", "tester") .put("avatarUrl", "https://skillhub.cn/avatar.png")); - SkillHubSessionClient.Account account = SkillHubSessionClient.parseAccount(root); + SkillHubSessionClient.Account account = client().parseAccount(root); assertEquals("测试用户", account.getDisplayName()); assertEquals("tester", account.getHandle()); @@ -32,7 +70,7 @@ public void parsesFallbackUserFields() throws Exception { .put("nickname", "昵称") .put("username", "name"); - SkillHubSessionClient.Account account = SkillHubSessionClient.parseAccount(root); + SkillHubSessionClient.Account account = client().parseAccount(root); assertEquals("昵称", account.getDisplayName()); assertEquals("name", account.getHandle()); @@ -46,7 +84,7 @@ public void collectsPublishFilesFromLocalSkill() throws Exception { SkillRecord skill = skill(root); List files = - SkillHubSessionClient.collectPublishFiles(skill); + client().collectPublishFiles(skill); assertEquals(2, files.size()); assertEquals("SKILL.md", files.get(0).path); @@ -60,7 +98,7 @@ public void rejectsSensitivePublishFile() throws Exception { write(root, ".env", "TOKEN=value"); try { - SkillHubSessionClient.collectPublishFiles(skill(root)); + client().collectPublishFiles(skill(root)); } catch (IllegalArgumentException error) { assertTrue(error.getMessage().contains("敏感文件")); return; @@ -85,6 +123,6 @@ private static void write(File root, String relative, String content) throws Exc @Test(expected = IllegalArgumentException.class) public void rejectsCookieHeaderInjection() { - SkillHubSessionClient.requireSafeCookie("sid=value\r\nX-Test: injected"); + client().requireSafeCookie("sid=value\r\nX-Test: injected"); } -} +} \ No newline at end of file diff --git a/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java b/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java index 740b2d8a..8cdc2ebc 100644 --- a/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java +++ b/app/src/test/java/cn/lineai/mvp/ExtensionManagementControllerTest.java @@ -39,6 +39,8 @@ public void skillHubInstallFromWorkerThreadDoesNotTouchHost() throws Exception { store, null, null, + null, + null, host); AtomicReference result = new AtomicReference<>(); AtomicReference failure = new AtomicReference<>(); @@ -88,5 +90,10 @@ public void refreshVisibleScreen(String screenId) { public void render() { renderCount++; } + + @Override + public void showSkillError(String message) { + // No-op for this test + } } } From 82807def70ff10353a26ceb9d419aa4e19ca4192 Mon Sep 17 00:00:00 2001 From: LangLang03 Date: Tue, 18 Aug 2026 16:35:16 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BF=84?= =?UTF-8?q?=E8=AF=AD=E7=BF=BB=E8=AF=91=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 258 条 Skill Hub 相关字符串的俄语翻译 - 覆盖错误消息、UI 标签、表单字段等所有用户可见文本 - 完成完整的国际化支持(英文、中文、俄语) --- app/src/main/res/values-ru/strings.xml | 258 +++++++++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c6e0132c..ab6119cf 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1207,4 +1207,262 @@ Тестовые данные — состояния не отражают реальные запуски инструментов. Руководство От новичка до профи: модели, рабочее пространство, Termux, SSH и MCP + + + + 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 \ No newline at end of file From ea6d45e319fa588073ea880b27024c542f6159e9 Mon Sep 17 00:00:00 2001 From: LangLang03 Date: Sun, 23 Aug 2026 23:16:50 +0800 Subject: [PATCH 5/8] build: bump version to 1.2.7-rc.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 修复上下文压缩循环问题 1. 调整压缩逻辑,仅标记baseIds消息为excludeFromContext,保留retained消息 2. 压缩成功后重置tokenUsageTracker,避免使用全量transcript的usage作为基线 3. 新增测试用例验证压缩后不会误触发压缩循环 --- app/build.gradle.kts | 2 +- .../cn/lineai/mvp/ContextCompactionController.java | 11 ++++++++--- .../cn/lineai/context/ContextCompactionService.java | 8 +++++--- .../lineai/context/ContextCompactionServiceTest.java | 12 ++++++++++++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb887469..67b42783 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.7-rc.1" val releaseApkName = "LineCode Pro $releaseVersionName.APK" val releaseIdsigName = "$releaseApkName.idsig" val releaseSigningProperties = Properties() 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/feature-model/src/main/java/cn/lineai/context/ContextCompactionService.java b/feature-model/src/main/java/cn/lineai/context/ContextCompactionService.java index bbfa113d..ccd7183f 100644 --- a/feature-model/src/main/java/cn/lineai/context/ContextCompactionService.java +++ b/feature-model/src/main/java/cn/lineai/context/ContextCompactionService.java @@ -310,8 +310,10 @@ private ContextCompactionResult compactWithOpenAiResponsesSummary( } /** - * codex 式压缩调用:独立模型请求生成摘要,失败时带退避重试,成功后把 - * 服务器观测到的 usage 记入 {@link TokenUsageTracker}(压缩后上下文的新基线)。 + * codex 式压缩调用:独立模型请求生成摘要,失败时带退避重试。压缩调用的输入是 + * 整个 transcript,其 usage 远大于压缩后实际上下文,不能作为压缩后基线,否则 + * shouldCompact 会误判仍超阈值导致压缩循环。这里 reset tracker,让触发判断 + * 回退到本地估算(本地估算已排除 excludeFromContext 消息)。 */ private ModelCompletionResponse streamSummaryWithRetry( SummaryStreamCall call, @@ -322,7 +324,7 @@ private ModelCompletionResponse streamSummaryWithRetry( try { ModelCompletionResponse response = call.call(); if (tokenUsageTracker != null) { - tokenUsageTracker.record(response); + tokenUsageTracker.reset(); } return response; } catch (ModelCompletionException e) { diff --git a/feature-model/src/test/java/cn/lineai/context/ContextCompactionServiceTest.java b/feature-model/src/test/java/cn/lineai/context/ContextCompactionServiceTest.java index 87eef999..fb64f291 100644 --- a/feature-model/src/test/java/cn/lineai/context/ContextCompactionServiceTest.java +++ b/feature-model/src/test/java/cn/lineai/context/ContextCompactionServiceTest.java @@ -35,6 +35,18 @@ public void shouldCompactFallsBackToEstimateWhenNoObservedUsage() { assertFalse(service.shouldCompact(messages, 100000, contextManager, true, 0)); } + @Test + public void shouldCompactReturnsFalseWhenAllMessagesExcludedAfterCompaction() { + // 模拟压缩后:旧消息标记 excludeFromContext,tracker reset(observed=0)。 + // 本地估算对 excludeFromContext 消息贡献 0,应远低于阈值,不触发压缩循环。 + ArrayList messages = new ArrayList<>(); + messages.add(new ChatMessage("m1", ChatMessage.Role.USER, repeat("a", 4000), false) + .withExcludeFromContext(true)); + messages.add(new ChatMessage("m2", ChatMessage.Role.ASSISTANT, repeat("b", 4000), false) + .withExcludeFromContext(true)); + assertFalse(service.shouldCompact(messages, 10000, contextManager, true, 0)); + } + @Test public void shouldSoftCompactUsesObservedInputTokensIn50To80Band() { ArrayList messages = new ArrayList<>(); From fddc1f30b90dfea193d9090dc21a3bd7d4aedb00 Mon Sep 17 00:00:00 2001 From: LangLang03 Date: Sat, 5 Sep 2026 19:55:58 +0800 Subject: [PATCH 6/8] refactor: simplify chat UI and stabilize prompt prefixes Rework conversation, tool approval, reasoning and retry displays; restore workspace selection and refine native screen layouts, card spacing and header actions. Keep request rules and tool schemas deterministic, append runtime context after history, and retain attachments with their user turns. Ignore temporary UI previews and add native layout and prompt regression coverage. --- .gitignore | 3 +- app/build.gradle.kts | 13 +- .../lineai/ai/prompt/ToolPromptRenderer.java | 35 +- .../lineai/ai/prompt/ToolPromptService.java | 19 +- .../CommandPermissionRepository.java | 51 ++ .../data/repository/SkillRepository.java | 4 + .../repository/ToolSettingsRepository.java | 10 + .../java/cn/lineai/mvp/ChatSessionStore.java | 23 + .../ConversationPersistenceController.java | 12 +- .../lineai/mvp/GenerationFlowController.java | 40 +- .../cn/lineai/mvp/GenerationFlowHost.java | 2 + .../java/cn/lineai/mvp/MainCoordinator.java | 19 +- .../cn/lineai/mvp/ModelPromptController.java | 41 +- .../lineai/mvp/PermissionModeController.java | 12 +- .../lineai/mvp/StreamingRenderController.java | 31 ++ .../mvp/ToolConfirmationController.java | 55 ++- .../java/cn/lineai/mvp/ToolRunController.java | 7 + .../mvp/agent/AgentExecutionController.java | 3 +- .../main/java/cn/lineai/ui/MainChatView.java | 14 +- .../lineai/ui/component/AboutScreenView.java | 2 +- .../cn/lineai/ui/component/ActionRowView.java | 17 +- .../ui/component/AdaptiveActionsView.java | 33 ++ .../component/AdvancedFeaturesScreenView.java | 58 +-- .../AgentExtensionEditScreenView.java | 4 +- .../ui/component/AssistantMessageView.java | 33 +- .../ui/component/AssistantTurnView.java | 322 ++++++++++++ .../component/AttachmentPickerSheetView.java | 27 +- .../lineai/ui/component/BottomSheetView.java | 33 +- .../lineai/ui/component/CardViewHelper.java | 78 +-- .../ui/component/ChatMessageListView.java | 114 ++--- .../cn/lineai/ui/component/ComposerView.java | 418 +++++----------- .../ui/component/ContextCompactBlockView.java | 8 +- .../cn/lineai/ui/component/DialogBuilder.java | 20 +- .../lineai/ui/component/DialogDimensions.java | 2 +- .../cn/lineai/ui/component/DialogManager.java | 6 +- .../component/DirectoryPickerSheetView.java | 46 +- .../ui/component/DisclosureSectionView.java | 40 ++ .../cn/lineai/ui/component/DrawerView.java | 52 +- .../ui/component/ErrorLogsScreenView.java | 1 - .../ui/component/ExtensionsScreenView.java | 58 +-- .../cn/lineai/ui/component/FileActionRow.java | 6 +- .../ui/component/FormTextFieldView.java | 12 +- .../cn/lineai/ui/component/HeaderView.java | 157 +++--- .../ui/component/InAppBrowserScreenView.java | 39 +- .../lineai/ui/component/InsetSheetLayout.java | 15 + .../cn/lineai/ui/component/LegalDialog.java | 6 +- .../lineai/ui/component/LineAlertDialog.java | 36 ++ .../ui/component/MCPSettingsScreenView.java | 48 +- .../component/MainChatViewLayoutBuilder.java | 13 +- .../component/McpExtensionEditScreenView.java | 4 +- .../component/MemorySettingsScreenView.java | 19 +- .../ui/component/MessageActionBarView.java | 6 +- .../component/ModelAddOptionsScreenView.java | 92 +--- .../ui/component/ModelAddScreenView.java | 124 +++-- .../lineai/ui/component/ModelFormHelper.java | 14 +- .../ui/component/ModelListScreenView.java | 46 +- .../ui/component/ModelPickerDialog.java | 11 +- .../cn/lineai/ui/component/OptionRowView.java | 11 +- .../component/PromptTemplatesScreenView.java | 4 +- .../ui/component/RefreshCwButtonView.java | 6 + .../lineai/ui/component/ScreenHeaderView.java | 87 ++-- .../ui/component/ScreenScaffoldView.java | 12 +- .../ui/component/ScreenSurfaceView.java | 17 + .../ui/component/SectionHeaderView.java | 14 +- .../component/SecuritySettingsScreenView.java | 2 +- .../ui/component/SettingsScreenView.java | 88 +--- .../ui/component/SettingsSectionView.java | 76 +-- .../ui/component/ShellCommandScreenView.java | 39 +- .../component/SimpleSettingsScreenView.java | 7 +- .../component/SkillHubCenterScreenView.java | 2 +- .../ui/component/SkillHubLoginScreenView.java | 2 +- .../component/SkillHubPublishScreenView.java | 2 +- .../ui/component/SkillHubWebScreenView.java | 2 +- .../component/SkillStoreDetailScreenView.java | 19 +- .../ui/component/SkillStoreScreenView.java | 21 +- .../ui/component/SlashCommandPopup.java | 19 +- .../ui/component/SshSettingsScreenView.java | 16 +- .../StorageManagementScreenView.java | 20 +- .../cn/lineai/ui/component/SwitchRowView.java | 7 +- .../TermuxIntegrationScreenView.java | 8 +- .../ui/component/TextSelectionDialog.java | 8 +- .../ui/component/ThemeSettingsScreenView.java | 2 + .../lineai/ui/component/ToolApprovalView.java | 151 ++++++ .../ui/component/ToolSettingsScreenView.java | 6 +- .../lineai/ui/component/UserMessageView.java | 22 +- .../lineai/ui/model/ConversationTimeline.java | 144 ++++++ .../lineai/ui/model/ProcessingDuration.java | 14 + app/src/main/res/values-night/colors.xml | 2 + app/src/main/res/values-ru/strings.xml | 30 +- app/src/main/res/values-v27/styles.xml | 2 +- app/src/main/res/values-zh/strings.xml | 28 +- app/src/main/res/values/colors.xml | 5 +- app/src/main/res/values/strings.xml | 28 +- app/src/main/res/values/styles.xml | 2 +- .../lineai/ai/prompt/ToolPromptOrderTest.java | 53 ++ .../CommandPermissionRepositoryTest.java | 62 +++ .../mvp/PermissionModeControllerTest.java | 13 +- .../cn/lineai/mvp/ProcessingStateTest.java | 84 ++++ .../cn/lineai/mvp/StablePromptPrefixTest.java | 136 +++++ .../mvp/ToolConfirmationControllerTest.java | 82 +++ .../lineai/tool/ToolPermissionPolicyTest.java | 133 +++++ .../lineai/ui/component/ChatLayoutTest.java | 262 ++++++++++ .../component/SecondaryScreenLayoutTest.java | 269 ++++++++++ .../ui/component/UiCorrectionsTest.java | 296 +++++++++++ .../ui/model/ConversationTimelineTest.java | 120 +++++ .../java/cn/lineai/model/ChatMessage.java | 52 +- .../java/cn/lineai/model/ChatUiState.java | 21 + .../java/cn/lineai/model/ThemePalette.java | 42 +- .../java/cn/lineai/model/ToolApproval.java | 16 + .../lineai/data/repository/MessageRecord.java | 19 +- .../data/repository/ToolSettingsStore.java | 4 + .../cn/lineai/log/ErrorLogRepository.java | 17 +- .../assets/prompts/system-prompt-template.txt | 8 +- .../cn/lineai/ai/ModelRequestOptions.java | 4 +- .../java/cn/lineai/ai/prompt/StableJson.java | 42 ++ .../cn/lineai/ai/prompt/StringTemplate.java | 16 +- .../ai/prompt/SystemPromptProvider.java | 23 +- .../protocol/AbstractHttpModelProtocol.java | 2 +- .../prompt/StablePromptSerializationTest.java | 56 +++ .../java/cn/lineai/tool/ToolExecutor.java | 9 + .../cn/lineai/tool/ToolPermissionService.java | 3 + gradle/libs.versions.toml | 2 + .../ui/markdown/MarkdownCodeBlockView.java | 12 +- .../lineai/ui/markdown/MarkdownRenderer.java | 14 +- .../ui/markdown/MarkdownTextBlockView.java | 2 +- .../lineai/terminalprovider/MainActivity.java | 93 ++-- .../terminalprovider/TerminalTheme.java | 24 + .../src/main/res/values-zh/strings.xml | 8 + .../src/main/res/values/strings.xml | 8 +- .../src/main/res/values/themes.xml | 4 +- .../java/cn/lineai/tool/ui/DiffLines.java | 67 +++ .../cn/lineai/tool/ui/ToolCallBlockView.java | 16 + .../cn/lineai/tool/ui/ToolCallExpansion.java | 8 + .../cn/lineai/tool/ui/util/ToolCallUtils.java | 2 +- .../lineai/tool/ui/view/BaseToolCallView.java | 4 +- .../java/cn/lineai/tool/ui/view/DiffView.java | 159 ++---- .../tool/ui/view/ToolCallDeleteView.java | 275 +++-------- .../tool/ui/view/ToolCallGenericView.java | 224 +++------ .../lineai/tool/ui/view/ToolCallReadView.java | 200 ++------ .../tool/ui/view/ToolCallShellView.java | 403 +++------------ .../lineai/tool/ui/view/ToolCallTodoView.java | 16 +- .../tool/ui/view/ToolCallWriteView.java | 465 +++++++----------- .../cn/lineai/tool/ui/view/ToolErrorView.java | 37 ++ tool-ui/src/main/res/values-ru/strings.xml | 9 + tool-ui/src/main/res/values-zh/strings.xml | 9 + tool-ui/src/main/res/values/strings.xml | 9 + .../java/cn/lineai/tool/ui/DiffLinesTest.java | 47 ++ .../cn/lineai/ui/theme/IconButtonView.java | 24 + .../java/cn/lineai/ui/theme/LineTheme.java | 40 +- .../cn/lineai/ui/theme/ThinkingBlockView.java | 31 +- 150 files changed, 4556 insertions(+), 2839 deletions(-) create mode 100644 app/src/main/java/cn/lineai/data/repository/CommandPermissionRepository.java create mode 100644 app/src/main/java/cn/lineai/ui/component/AdaptiveActionsView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/AssistantTurnView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/DisclosureSectionView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/InsetSheetLayout.java create mode 100644 app/src/main/java/cn/lineai/ui/component/LineAlertDialog.java create mode 100644 app/src/main/java/cn/lineai/ui/component/ScreenSurfaceView.java create mode 100644 app/src/main/java/cn/lineai/ui/component/ToolApprovalView.java create mode 100644 app/src/main/java/cn/lineai/ui/model/ConversationTimeline.java create mode 100644 app/src/main/java/cn/lineai/ui/model/ProcessingDuration.java create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/test/java/cn/lineai/ai/prompt/ToolPromptOrderTest.java create mode 100644 app/src/test/java/cn/lineai/data/repository/CommandPermissionRepositoryTest.java create mode 100644 app/src/test/java/cn/lineai/mvp/ProcessingStateTest.java create mode 100644 app/src/test/java/cn/lineai/mvp/StablePromptPrefixTest.java create mode 100644 app/src/test/java/cn/lineai/mvp/ToolConfirmationControllerTest.java create mode 100644 app/src/test/java/cn/lineai/tool/ToolPermissionPolicyTest.java create mode 100644 app/src/test/java/cn/lineai/ui/component/ChatLayoutTest.java create mode 100644 app/src/test/java/cn/lineai/ui/component/SecondaryScreenLayoutTest.java create mode 100644 app/src/test/java/cn/lineai/ui/component/UiCorrectionsTest.java create mode 100644 app/src/test/java/cn/lineai/ui/model/ConversationTimelineTest.java create mode 100644 core-model/src/main/java/cn/lineai/model/ToolApproval.java create mode 100644 feature-model/src/main/java/cn/lineai/ai/prompt/StableJson.java create mode 100644 feature-model/src/test/java/cn/lineai/ai/prompt/StablePromptSerializationTest.java create mode 100644 terminal-provider/src/main/java/cn/lineai/terminalprovider/TerminalTheme.java create mode 100644 terminal-provider/src/main/res/values-zh/strings.xml create mode 100644 tool-ui/src/main/java/cn/lineai/tool/ui/DiffLines.java create mode 100644 tool-ui/src/main/java/cn/lineai/tool/ui/ToolCallExpansion.java create mode 100644 tool-ui/src/main/java/cn/lineai/tool/ui/view/ToolErrorView.java create mode 100644 tool-ui/src/test/java/cn/lineai/tool/ui/DiffLinesTest.java 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 67b42783..28297832 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.7-rc.1" +val releaseVersionName = "1.2.7-rc.2" 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) { @@ -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/SkillRepository.java b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java index f012f3b9..b3f05c1d 100644 --- a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java @@ -200,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"); @@ -222,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"); @@ -237,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"); @@ -264,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/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/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/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 f479d967..856cb5af 100644 --- a/app/src/main/java/cn/lineai/mvp/MainCoordinator.java +++ b/app/src/main/java/cn/lineai/mvp/MainCoordinator.java @@ -963,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(); @@ -1030,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/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 b0a1e378..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 @@ -486,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 c06fa4b9..6af69b2c 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(); @@ -117,9 +117,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) { @@ -183,15 +189,11 @@ 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); boolean streamingText = message.isStreaming() && message.getContent().trim().length() > 0; setCursorVisible(streamingText); - if (!lastAnimatedMessageId.equals(messageId)) { - lastAnimatedMessageId = messageId; - setAlpha(0f); - animate().alpha(1f).setDuration(ENTRANCE_FADE_MS).start(); - } lastMessageId = messageId; lastReasoning = safeReasoning; lastContent = content; @@ -202,6 +204,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 setCursorVisible(boolean visible) { if (visible) { if (cursorView.getVisibility() != VISIBLE) { 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 94314cac..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); @@ -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/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 8dc2744c..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,14 +333,14 @@ 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) { 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/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 9dcba409..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)); } 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 index 97763f95..1ee29f3b 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubCenterScreenView.java @@ -17,7 +17,7 @@ public interface Listener { 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, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 28, 8, 28, 48); TextView notice = LineTheme.text(context, context.getString(cn.lineai.R.string.skillhub_center_notice), diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java index 4efc8fdb..0cb141cc 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubLoginScreenView.java @@ -25,7 +25,7 @@ import java.util.HashSet; import java.util.Set; -public final class SkillHubLoginScreenView extends LinearLayout { +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", diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java index 7b18008b..73c6e8e2 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubPublishScreenView.java @@ -51,7 +51,7 @@ public SkillHubPublishScreenView( } LinearLayout content = getContent(); - LineTheme.padding(content, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 28, 8, 28, 48); addNotice(content); selected = fieldButton(content); slug = field(content, context.getString(cn.lineai.R.string.skillhub_slug_label), diff --git a/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java index 9169d323..273aa66f 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillHubWebScreenView.java @@ -23,7 +23,7 @@ import java.util.Map; import java.util.Set; -public final class SkillHubWebScreenView extends LinearLayout { +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", diff --git a/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java index 83e30b99..9b2d0422 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java @@ -72,7 +72,7 @@ public SkillStoreDetailScreenView(Context context, String slug, Listener listene this.sessionClient = new SkillHubSessionClient(new ContextResourceProvider(context)); this.iconLoader = new SkillIconLoader(context); body = getContent(); - LineTheme.padding(body, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(body, 28, 8, 28, 48); progress = new ProgressBar(context); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); @@ -107,9 +107,8 @@ private void render(SkillHubModels.Detail value) { private void addHero(SkillHubModels.Detail value) { LinearLayout hero = new LinearLayout(getContext()); hero.setOrientation(VERTICAL); - hero.setBackground(LineTheme.roundedStroke( - getContext(), LineTheme.SURFACE_ELEVATED, 14, LineTheme.BORDER)); - LineTheme.padding(hero, LineTheme.LG, LineTheme.LG, LineTheme.LG, LineTheme.LG); + hero.setBackground(null); + LineTheme.padding(hero, 0, 8, 0, 24); LinearLayout top = new LinearLayout(getContext()); top.setOrientation(HORIZONTAL); @@ -188,7 +187,7 @@ private void addHero(SkillHubModels.Detail value) { } private void addActions(SkillHubModels.Detail value) { - LinearLayout actions = new LinearLayout(getContext()); + LinearLayout actions = new AdaptiveActionsView(getContext()); actions.setOrientation(HORIZONTAL); actions.setGravity(Gravity.CENTER_VERTICAL); actions.addView(actionButton(IconButtonView.COPY, getString(R.string.skillhub_copy_prompt), @@ -523,7 +522,7 @@ private void showFilePreview(String path, String content) { text.setTextIsSelectable(true); host.addView(text); } - new AlertDialog.Builder(getContext()) + new LineAlertDialog.Builder(getContext()) .setTitle(path) .setView(host) .setNegativeButton(getString(R.string.skillhub_close), null) @@ -620,7 +619,7 @@ private void showCommentDialog( inputParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); panel.addView(input, inputParams); - LinearLayout actions = new LinearLayout(getContext()); + 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); @@ -696,7 +695,7 @@ private void addComment(LinearLayout section, SkillHubModels.Comment comment, in contentParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); card.addView(content, contentParams); - LinearLayout actions = new LinearLayout(getContext()); + LinearLayout actions = new AdaptiveActionsView(getContext()); actions.setOrientation(HORIZONTAL); TextView like = commentAction( (comment.isLiked() ? getString(R.string.skillhub_unlike) @@ -1111,7 +1110,7 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut packageIcon.setIconSizeDp(42, 22); packageIcon.setClickable(false); packageIcon.setFocusable(false); - packageIcon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 10)); + packageIcon.setBackground(null); heading.addView(packageIcon, new LinearLayout.LayoutParams( LineTheme.dp(getContext(), 42), LineTheme.dp(getContext(), 42))); LinearLayout titleCopy = new LinearLayout(getContext()); @@ -1209,7 +1208,7 @@ IconButtonView.FOLDER, getString(R.string.skillhub_location_project_title), panel.addView(warningCard, warningParams); } - LinearLayout actions = new LinearLayout(getContext()); + 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); diff --git a/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java b/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java index 603aadcd..1d0642eb 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreScreenView.java @@ -55,13 +55,13 @@ public interface Listener { private String sortBy = "downloads"; public SkillStoreScreenView(Context context, Listener listener) { - super(context, context.getString(R.string.skillhub_title_store), listener::onBack, null); + 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, LineTheme.LG, LineTheme.LG, LineTheme.LG, 100); + LineTheme.padding(content, 16, 16, 16, 32); addIntro(content); search = addSearch(content); @@ -122,7 +122,7 @@ private void addIntro(LinearLayout content) { storeIcon.setIconSizeDp(44, 24); storeIcon.setClickable(false); storeIcon.setFocusable(false); - storeIcon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 12)); + storeIcon.setBackground(null); header.addView(storeIcon, new LinearLayout.LayoutParams( LineTheme.dp(getContext(), 44), LineTheme.dp(getContext(), 44))); content.addView(header); @@ -393,7 +393,7 @@ private void showAccountDialog(SkillHubSessionClient.Account account) { statusParams.topMargin = LineTheme.dp(getContext(), LineTheme.MD); panel.addView(status, statusParams); - LinearLayout actions = new LinearLayout(getContext()); + LinearLayout actions = new AdaptiveActionsView(getContext()); actions.setOrientation(HORIZONTAL); TextView close = accountDialogButton( getContext().getString(R.string.skillhub_continue), false); @@ -535,9 +535,8 @@ private View card(SkillHubModels.Summary skill) { card.setFocusable(true); card.setContentDescription(skill.getName() + getContext().getString(R.string.skillhub_view_details)); card.setOnClickListener(v -> listener.onOpen(skill.getSlug())); - card.setBackground(LineTheme.roundedStroke( - getContext(), LineTheme.SURFACE_ELEVATED, 12, LineTheme.BORDER)); - LineTheme.padding(card, LineTheme.MD, LineTheme.MD, LineTheme.SM, LineTheme.MD); + 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); @@ -548,7 +547,7 @@ private View card(SkillHubModels.Summary skill) { icon.setIconSizeDp(48, 25); icon.setClickable(false); icon.setFocusable(false); - icon.setBackground(LineTheme.rounded(getContext(), LineTheme.ACCENT_MUTED, 12)); + card.addView(icon, new LinearLayout.LayoutParams( LineTheme.dp(getContext(), 48), LineTheme.dp(getContext(), 48))); iconLoader.load(skill.getIconUrl(), icon); @@ -566,7 +565,7 @@ private View card(SkillHubModels.Summary skill) { titleRow.setGravity(Gravity.CENTER_VERTICAL); TextView title = LineTheme.textMedium(getContext(), skill.getName(), LineTheme.FONT_MD, LineTheme.TEXT); - title.setMaxLines(1); + title.setMaxLines(2); titleRow.addView(title, new LinearLayout.LayoutParams( 0, LayoutParams.WRAP_CONTENT, 1f)); if (skill.isVerified()) { @@ -598,7 +597,7 @@ private View card(SkillHubModels.Summary skill) { apiParams.leftMargin = LineTheme.dp(getContext(), LineTheme.XS); tags.addView(apiKey, apiParams); } - copy.addView(tags, tagsParams); + } TextView description = LineTheme.text(getContext(), skill.getDescription(), @@ -616,7 +615,7 @@ private View card(SkillHubModels.Summary skill) { LinearLayout.LayoutParams statsParams = new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); statsParams.topMargin = LineTheme.dp(getContext(), LineTheme.XS); - copy.addView(stats, statsParams); + IconButtonView chevron = new IconButtonView(getContext(), IconButtonView.CHEVRON_RIGHT); chevron.setIconColor(LineTheme.TEXT_TERTIARY); 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 f7f33c64..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()); @@ -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 ab6119cf..d90ad28e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1148,7 +1148,7 @@ Авто - Автоматически выполнять включённые инструменты; опасные инструменты подтверждаются по политике + Автоматически выполнять включённые инструменты без отдельного подтверждения Подтверждение Опасные операции требуют подтверждения перед выполнением Только чтение @@ -1465,4 +1465,30 @@ Онлайн магазин Магазин Skills SkillHub Просматривайте, ищите и безопасно устанавливайте community Skills - \ No newline at end of file + Добавить в диалог + Файлы проекта + Режим · %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 a9db16b1..8e65c026 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1151,7 +1151,7 @@ 自动 - 自动执行已启用工具,危险工具按策略确认 + 自动执行已启用工具,无需逐次确认 确认 危险操作需要确认后执行 只读 @@ -1468,4 +1468,30 @@ 在线商店 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 01066504..1f1f0fb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1150,7 +1150,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 @@ -1467,4 +1467,30 @@ 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 2eb5c83d..f90eda3c 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 @@ -38,23 +38,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; @@ -68,7 +64,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); @@ -99,19 +95,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 From de5f98dc3fa01f9366c9cfe78761fa5e683f98ed Mon Sep 17 00:00:00 2001 From: LangLang03 Date: Sat, 5 Sep 2026 20:07:23 +0800 Subject: [PATCH 7/8] release: prepare 1.2.8-max and document changes since v1.2.6 --- app/build.gradle.kts | 4 +- update.md | 104 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28297832..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.7-rc.2" +val releaseVersionName = "1.2.8-max" val releaseApkName = "LineCode Pro $releaseVersionName.APK" val releaseIdsigName = "$releaseApkName.idsig" val releaseSigningProperties = Properties() @@ -113,7 +113,7 @@ android { applicationId = "cn.lineai" minSdk = 26 targetSdk = 37 - versionCode = 31 + versionCode = 32 versionName = releaseVersionName } diff --git a/update.md b/update.md index fb004fb8..35f91a98 100644 --- a/update.md +++ b/update.md @@ -1,5 +1,109 @@ # 更新日志 +## v1.2.8-max + +### 原生 UI 与聊天布局重写 + +- **原生界面重构** - 重新整理聊天页、设置页、抽屉、底部弹窗和工具卡片的布局,统一页面底色、卡片层级、圆角、字体与图标比例;继续使用 Android 原生 View,减少重复边框、过大的标题和占用空间过多的装饰组件 +- **聊天导航恢复与调整** - 左上角保留三条杠菜单,点击工作区标题打开工作区选择抽屉;恢复权限选择器与三点菜单,新对话使用加号图标,附件加号继续用于选择文件,设置与会话导航保留在抽屉中 +- **用户消息与输入框** - 用户消息改为右对齐气泡,区别于助手正文和过程说明;收紧输入框默认高度、内部留白与底部操作区,调整正文到屏幕两侧的距离 +- **统一页头操作尺寸** - `ScreenHeaderView` 统一返回及图标操作按钮的 48dp 点击区域与 22dp 图标尺寸,修复错误日志、添加模型等页面右上角按钮过大,以及保存操作贴到屏幕最右侧的问题 +- **自适应页面与弹窗** - 新增 `ScreenSurfaceView`、`InsetSheetLayout`、`AdaptiveActionsView` 等共享布局组件;宽屏限制内容宽度,底部弹窗限制最大高度,长内容可以滚动,窄屏操作按钮可以重新排列 +- **Markdown 排版调整** - 调整正文、标题、代码块的字号和间距,代码区域使用更紧凑的显示方式;保留链接、复制、代码换行等阅读操作 + +### 处理时间线、思考与最终输出 + +- **按回合组织处理过程** - 新增 `ConversationTimeline` 与 `AssistantTurnView`,将同一轮助手的过程文字、工具调用和结果组织到「处理中 / 已处理」区域,最终答复在折叠区域之外显示;普通无工具、无错误的文本答复不额外生成已处理区域 +- **连续工具调用分组折叠** - 中途没有正文输出的连续工具调用合并为一个工具组,默认收起,手动展开后查看各次调用;中间出现助手正文时结束当前分组,后续工具重新分组,展开状态在界面刷新时保留 +- **思考按发生顺序追加** - 每次思考作为独立组件进入对应位置,不再集中到顶部的单一思考区;连续工具执行期间的内部推理随工具过程收纳,不单独打断外层时间线,出现正文后再调用工具时继续按消息顺序展示思考 +- **Agent 独立展示** - 子 Agent 与 Agent 流水线继续使用各自的专用卡片,在处理时间线中直接展示,不并入需要再次展开的普通「使用了工具」汇总;保留 Agent 进度、内部输出和展开操作 +- **内部结束标记处理** - 系统工具规则加入 `` 作为处理结束标记;流式渲染隐藏从 ` Date: Sat, 5 Sep 2026 20:24:06 +0800 Subject: [PATCH 8/8] docs: record reasoning compatibility and final PR validation --- update.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/update.md b/update.md index 792989df..a20939ae 100644 --- a/update.md +++ b/update.md @@ -16,6 +16,7 @@ - **按回合组织处理过程** - 新增 `ConversationTimeline` 与 `AssistantTurnView`,将同一轮助手的过程文字、工具调用和结果组织到「处理中 / 已处理」区域,最终答复在折叠区域之外显示;普通无工具、无错误的文本答复不额外生成已处理区域 - **连续工具调用分组折叠** - 中途没有正文输出的连续工具调用合并为一个工具组,默认收起,手动展开后查看各次调用;中间出现助手正文时结束当前分组,后续工具重新分组,展开状态在界面刷新时保留 - **思考按发生顺序追加** - 每次思考作为独立组件进入对应位置,不再集中到顶部的单一思考区;连续工具执行期间的内部推理随工具过程收纳,不单独打断外层时间线,出现正文后再调用工具时继续按消息顺序展示思考 +- **工作状态与摘要兼容** - 整合主分支的 `WorkingStatusView`、OpenAI / Codex 思考摘要解析与思考文本强调样式;保留无正文时的工作状态,完成后隐藏状态行,同时维持新版消息操作栏默认隐藏、长按展开的交互 - **Agent 独立展示** - 子 Agent 与 Agent 流水线继续使用各自的专用卡片,在处理时间线中直接展示,不并入需要再次展开的普通「使用了工具」汇总;保留 Agent 进度、内部输出和展开操作 - **内部结束标记处理** - 系统工具规则加入 `` 作为处理结束标记;流式渲染隐藏从 `