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/3] =?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/3] =?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 60c264e59b9e731728ab18f5765c96a271b1206c Mon Sep 17 00:00:00 2001 From: andTDWF <120239807+andTDWF@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:41:33 +0800 Subject: [PATCH 3/3] fix(skill): restore SSH and IPC SkillHub installation --- .../data/repository/ExtensionRepository.java | 16 +- .../data/repository/SkillRepository.java | 186 +++++++++++++++++- .../mvp/ExtensionManagementController.java | 6 + .../java/cn/lineai/mvp/MainDependencies.java | 20 +- .../component/SkillStoreDetailScreenView.java | 30 +++ .../java/cn/lineai/model/SkillRecord.java | 7 + .../data/repository/ExtensionStore.java | 10 + 7 files changed, 263 insertions(+), 12 deletions(-) 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 2c61b93a..0758da44 100644 --- a/app/src/main/java/cn/lineai/data/repository/ExtensionRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/ExtensionRepository.java @@ -23,10 +23,14 @@ public final class ExtensionRepository extends BaseRepository implements Extensi private final SkillRepository skillRepository; public ExtensionRepository(LineCodeDatabase database, ResourceProvider resourceProvider, SkillFileManager fileManager, SkillPromptProvider promptProvider) { + this(database, resourceProvider, fileManager, promptProvider, null, null); + } + + public ExtensionRepository(LineCodeDatabase database, ResourceProvider resourceProvider, SkillFileManager fileManager, SkillPromptProvider promptProvider, cn.lineai.ssh.SshService sshService, cn.lineai.ipc.IpcProviderManager ipcProviderManager) { super(database); this.agentRepository = new AgentExtensionRepository(database); this.mcpRepository = new McpExtensionRepository(database); - this.skillRepository = new SkillRepository(database, resourceProvider, fileManager, this.agentRepository, this.mcpRepository, promptProvider); + this.skillRepository = new SkillRepository(database, resourceProvider, fileManager, this.agentRepository, this.mcpRepository, promptProvider, sshService, ipcProviderManager); } @Override @@ -110,6 +114,16 @@ public synchronized SkillRecord installSkillFromSkillHub(String homePath, String return skillRepository.installSkillFromSkillHub(homePath, location, slug, version); } + @Override + public synchronized SkillRecord installSkillFromSkillHubSsh(String location, String slug, String version) throws Exception { + return skillRepository.installSkillFromSkillHubSsh(location, slug, version); + } + + @Override + public synchronized SkillRecord installSkillFromSkillHubIpc(String location, String slug, String version) throws Exception { + return skillRepository.installSkillFromSkillHubIpc(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 38970f70..daafe8dd 100644 --- a/app/src/main/java/cn/lineai/data/repository/SkillRepository.java +++ b/app/src/main/java/cn/lineai/data/repository/SkillRepository.java @@ -9,6 +9,9 @@ import cn.lineai.data.service.GitHubSkillInstaller; import cn.lineai.data.service.SkillFileManager; import cn.lineai.data.service.SkillHubClient; +import cn.lineai.ipc.IpcProviderManager; +import cn.lineai.ipc.IpcProviderType; +import cn.lineai.ipc.terminal.TerminalIpcProvider; import cn.lineai.model.ExtensionAgentConfig; import cn.lineai.model.ExtensionMcpConfig; import cn.lineai.model.McpToolSummary; @@ -36,14 +39,22 @@ public final class SkillRepository extends BaseRepository { private final SkillPromptProvider promptProvider; private final AgentExtensionRepository agentRepository; private final McpExtensionRepository mcpRepository; + private final cn.lineai.ssh.SshService sshService; + private final IpcProviderManager ipcProviderManager; public SkillRepository(LineCodeDatabase database, ResourceProvider resourceProvider, SkillFileManager fileManager, AgentExtensionRepository agentRepository, McpExtensionRepository mcpRepository, SkillPromptProvider promptProvider) { + this(database, resourceProvider, fileManager, agentRepository, mcpRepository, promptProvider, null, null); + } + + public SkillRepository(LineCodeDatabase database, ResourceProvider resourceProvider, SkillFileManager fileManager, AgentExtensionRepository agentRepository, McpExtensionRepository mcpRepository, SkillPromptProvider promptProvider, cn.lineai.ssh.SshService sshService, IpcProviderManager ipcProviderManager) { super(database); this.resourceProvider = resourceProvider; this.fileManager = fileManager; this.promptProvider = promptProvider; this.agentRepository = agentRepository; this.mcpRepository = mcpRepository; + this.sshService = sshService; + this.ipcProviderManager = ipcProviderManager; } public synchronized List getSkills(String homePath) { @@ -151,6 +162,177 @@ public synchronized SkillRecord installSkillFromSkillHub( return installTemporarySkill(homePath, location, archive, slug, tempDir); } + public synchronized SkillRecord installSkillFromSkillHubSsh( + String location, + String slug, + String version + ) throws Exception { + if (sshService == null) { + throw new IllegalStateException("SSH 服务未配置。"); + } + return installSkillToSsh(slug, version); + } + + public synchronized SkillRecord installSkillFromSkillHubIpc( + String location, + String slug, + String version + ) throws Exception { + if (ipcProviderManager == null) { + throw new IllegalStateException("IPC 终端服务未配置。"); + } + return installSkillToIpc(slug, version); + } + + private SkillRecord installSkillToSsh(String slug, String version) throws Exception { + File tempDir = prepareSkillHubArchive(slug, version); + try { + File skillMd = fileManager.findSkillMd(tempDir, 0); + if (skillMd == null) { + throw new IllegalArgumentException("Skill 包缺少 SKILL.md。"); + } + File skillRoot = skillMd.getParentFile(); + SkillRecord local = fileManager.parseSkill(skillRoot, skillMd, SkillRecord.LOCATION_SSH); + String baseName = fileManager.sanitizeFileName(slug); + String remoteRoot = sshService.withSftp(sftp -> { + sftp.cd("~"); + mkdirIfMissing(sftp, ".linecode"); + sftp.cd(".linecode"); + mkdirIfMissing(sftp, "skills"); + sftp.cd("skills"); + String targetName = baseName; + if (exists(sftp, targetName)) { + targetName += "_" + System.currentTimeMillis(); + } + sftp.mkdir(targetName); + sftp.cd(targetName); + uploadSshDirectory(sftp, skillRoot); + return "~/.linecode/skills/" + targetName; + }, 120000); + SkillRecord record = remoteRecord(local, remoteRoot, remoteRoot + "/SKILL.md", SkillRecord.LOCATION_SSH); + upsertDiscoveredSkills(Collections.singletonList(record)); + return record; + } finally { + fileManager.deleteRecursive(tempDir); + } + } + + private SkillRecord installSkillToIpc(String slug, String version) throws Exception { + File tempDir = prepareSkillHubArchive(slug, version); + try { + File skillMd = fileManager.findSkillMd(tempDir, 0); + if (skillMd == null) { + throw new IllegalArgumentException("Skill 包缺少 SKILL.md。"); + } + File skillRoot = skillMd.getParentFile(); + SkillRecord local = fileManager.parseSkill(skillRoot, skillMd, SkillRecord.LOCATION_IPC); + TerminalIpcProvider provider = (TerminalIpcProvider) ipcProviderManager.getProviderByType(IpcProviderType.TERMINAL); + if (provider == null) { + throw new IllegalStateException("IPC 终端服务未绑定。"); + } + String home = provider.getHomePath(); + if (home == null || home.trim().length() == 0) { + throw new IllegalStateException("IPC 终端未返回主目录。"); + } + String root = home + "/.linecode/skills"; + provider.executeShell("mkdir -p " + remoteShellQuote(root), null, 30000, null); + String target = root + "/" + fileManager.sanitizeFileName(slug); + if (provider.fileExists(target)) { + target += "_" + System.currentTimeMillis(); + } + provider.executeShell("mkdir -p " + remoteShellQuote(target), null, 30000, null); + uploadIpcDirectory(provider, skillRoot, target); + SkillRecord record = remoteRecord(local, target, target + "/SKILL.md", SkillRecord.LOCATION_IPC); + upsertDiscoveredSkills(Collections.singletonList(record)); + return record; + } finally { + fileManager.deleteRecursive(tempDir); + } + } + + private File prepareSkillHubArchive(String slug, String version) throws Exception { + File tempRoot = new File(fileManager.getWorkspacePaths().getLinecodeRoot(), "tmp/skills-skillhub-remote"); + File tempDir = fileManager.uniqueChild(tempRoot, fileManager.sanitizeFileName(slug)); + try { + File archive = new File(tempDir, "skill.zip"); + fileManager.writeBytes(archive, new SkillHubClient().download(slug, version)); + fileManager.unzip(archive, tempDir); + archive.delete(); + return tempDir; + } catch (Exception error) { + fileManager.deleteRecursive(tempDir); + throw error; + } + } + + private SkillRecord remoteRecord(SkillRecord local, String rootPath, String skillMdPath, String location) { + return new SkillRecord( + fileManager.skillId(location, skillMdPath), local.getName(), local.getDescription(), + rootPath, skillMdPath, location, true, local.getDiscoveredAt(), System.currentTimeMillis()); + } + + private void mkdirIfMissing(com.jcraft.jsch.ChannelSftp sftp, String name) throws Exception { + try { + sftp.stat(name); + } catch (Exception ignored) { + sftp.mkdir(name); + } + } + + private boolean exists(com.jcraft.jsch.ChannelSftp sftp, String name) { + try { + sftp.stat(name); + return true; + } catch (Exception ignored) { + return false; + } + } + + private void uploadSshDirectory(com.jcraft.jsch.ChannelSftp sftp, File directory) throws Exception { + File[] children = directory.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + mkdirIfMissing(sftp, child.getName()); + sftp.cd(child.getName()); + uploadSshDirectory(sftp, child); + sftp.cd(".."); + } else { + java.io.FileInputStream input = new java.io.FileInputStream(child); + try { + sftp.put(input, child.getName(), com.jcraft.jsch.ChannelSftp.OVERWRITE); + } finally { + input.close(); + } + } + } + } + + private void uploadIpcDirectory(TerminalIpcProvider provider, File directory, String remoteDirectory) throws Exception { + File[] children = directory.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + String remotePath = remoteDirectory + "/" + child.getName(); + if (child.isDirectory()) { + provider.executeShell("mkdir -p " + remoteShellQuote(remotePath), null, 30000, null); + uploadIpcDirectory(provider, child, remotePath); + } else { + byte[] bytes = java.nio.file.Files.readAllBytes(child.toPath()); + if (!provider.writeFile(remotePath, bytes)) { + throw new IllegalStateException("IPC 写入 Skill 文件失败: " + remotePath); + } + } + } + } + + private String remoteShellQuote(String value) { + return "'" + (value == null ? "" : value).replace("'", "'\\\"'\\\"'") + "'"; + } + private SkillRecord installTemporarySkill( String homePath, String location, @@ -181,7 +363,9 @@ public synchronized void setSkillEnabled(String id, boolean enabled) { public synchronized void deleteSkill(String id) { SkillRecord target = findSkill(id); database.getWritableDatabase().delete("skills", "id = ?", new String[] {safe(id)}); - if (target != null && !SkillRecord.LOCATION_SSH.equals(target.getLocation()) && target.getRootPath().length() > 0) { + if (target != null && !SkillRecord.LOCATION_SSH.equals(target.getLocation()) + && !SkillRecord.LOCATION_IPC.equals(target.getLocation()) + && target.getRootPath().length() > 0) { fileManager.deleteRecursive(new File(target.getRootPath())); } } diff --git a/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java b/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java index 74229753..4b8adbab 100644 --- a/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java +++ b/app/src/main/java/cn/lineai/mvp/ExtensionManagementController.java @@ -97,6 +97,12 @@ SkillRecord installSkillFromGitHub(String location, String githubUrl) throws Exc } SkillRecord installSkillFromSkillHub(String location, String slug, String version) throws Exception { + if (SkillRecord.LOCATION_SSH.equals(location)) { + return extensionRepository.installSkillFromSkillHubSsh(location, slug, version); + } + if (SkillRecord.LOCATION_IPC.equals(location)) { + return extensionRepository.installSkillFromSkillHubIpc(location, slug, version); + } return extensionRepository.installSkillFromSkillHub( host.projectPath(), location, slug, version); } diff --git a/app/src/main/java/cn/lineai/mvp/MainDependencies.java b/app/src/main/java/cn/lineai/mvp/MainDependencies.java index be33a895..3542462a 100644 --- a/app/src/main/java/cn/lineai/mvp/MainDependencies.java +++ b/app/src/main/java/cn/lineai/mvp/MainDependencies.java @@ -167,6 +167,14 @@ public boolean isAccessibilityEnabled() { ToolSettingsRepository toolSettingsRepo = new ToolSettingsRepository(resourceProvider, settingsRepository, webSearchConfigRepository, phoneControlRepository, categoryResolver); toolSettingsRepository = toolSettingsRepo; cn.lineai.data.service.SkillFileManager skillFileManager = new cn.lineai.data.service.SkillFileManager(workspacePaths, appContext, resourceProvider); + ipcProviderRepository = new IpcProviderRepository(database); + ipcProviderScanner = new IpcProviderScanner(); + ipcProviderManager = new IpcProviderManager(context); + diffRepository = new DiffRepository(database); + fileTreeRepository = new FileTreeRepository(); + sshService = new SshService(context); + sshFileTreeRepository = new SshFileTreeRepository(sshService); + ipcFileTreeRepository = new IpcFileTreeRepository(ipcProviderManager); extensionRepository = new ExtensionRepository(database, resourceProvider, skillFileManager, new cn.lineai.ai.SkillPromptProvider() { @Override public String buildExtensionPrompt(String skillName, String skillContent, String workDirectory) { @@ -178,19 +186,11 @@ public String buildExtensionPrompt(String skillName, String skillContent, String } return sb.toString(); } - }); + }, sshService, ipcProviderManager); memoryExtractionService = new MemoryExtractionService(resourceProvider, learningContextRepository, extensionRepository, promptTemplateRepository); - ipcProviderRepository = new IpcProviderRepository(database); - ipcProviderScanner = new IpcProviderScanner(); - ipcProviderManager = new IpcProviderManager(context); - diffRepository = new DiffRepository(database); - fileTreeRepository = new FileTreeRepository(); - sshService = new SshService(context); - sshFileTreeRepository = new SshFileTreeRepository(sshService); - ipcFileTreeRepository = new IpcFileTreeRepository(ipcProviderManager); - contextManager = new ContextManager(); modelClient = new ModelClient(); tokenUsageTracker = new TokenUsageTracker(); + contextManager = new ContextManager(); contextCompactionService = new ContextCompactionService( modelClient, new OpenAiResponsesCompactionProtocol(), 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..4ceb0b16 100644 --- a/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java +++ b/app/src/main/java/cn/lineai/ui/component/SkillStoreDetailScreenView.java @@ -1117,6 +1117,10 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut IconButtonView.SMARTPHONE, "App 全局 Skills", "所有工作区均可使用", true); LinearLayout projectOption = installLocationOption( IconButtonView.FOLDER, "当前项目", ".linecode/skills · 仅当前工作区", false); + LinearLayout sshOption = installLocationOption( + IconButtonView.MONITOR, "SSH 工作区", "~/.linecode/skills · 通过 SSH 写入", false); + LinearLayout ipcOption = installLocationOption( + IconButtonView.CPU, "IPC 工作区", "~/.linecode/skills · 通过终端 IPC 写入", false); LinearLayout.LayoutParams optionParams = new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); optionParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); @@ -1125,15 +1129,41 @@ private void showInstallConfirm(SkillHubModels.Detail value, TextView installBut LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); projectParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); panel.addView(projectOption, projectParams); + LinearLayout.LayoutParams sshParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + sshParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + panel.addView(sshOption, sshParams); + LinearLayout.LayoutParams ipcParams = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + ipcParams.topMargin = LineTheme.dp(getContext(), LineTheme.SM); + panel.addView(ipcOption, ipcParams); appOption.setOnClickListener(v -> { selectedLocation[0] = SkillRecord.LOCATION_APP; styleInstallLocation(appOption, true); styleInstallLocation(projectOption, false); + styleInstallLocation(sshOption, false); + styleInstallLocation(ipcOption, false); }); projectOption.setOnClickListener(v -> { selectedLocation[0] = SkillRecord.LOCATION_PROJECT; styleInstallLocation(appOption, false); styleInstallLocation(projectOption, true); + styleInstallLocation(sshOption, false); + styleInstallLocation(ipcOption, false); + }); + sshOption.setOnClickListener(v -> { + selectedLocation[0] = SkillRecord.LOCATION_SSH; + styleInstallLocation(appOption, false); + styleInstallLocation(projectOption, false); + styleInstallLocation(sshOption, true); + styleInstallLocation(ipcOption, false); + }); + ipcOption.setOnClickListener(v -> { + selectedLocation[0] = SkillRecord.LOCATION_IPC; + styleInstallLocation(appOption, false); + styleInstallLocation(projectOption, false); + styleInstallLocation(sshOption, false); + styleInstallLocation(ipcOption, true); }); if (value.hasScripts() || value.requiresApiKey()) { diff --git a/core-model/src/main/java/cn/lineai/model/SkillRecord.java b/core-model/src/main/java/cn/lineai/model/SkillRecord.java index 1a104f1a..f24969ed 100644 --- a/core-model/src/main/java/cn/lineai/model/SkillRecord.java +++ b/core-model/src/main/java/cn/lineai/model/SkillRecord.java @@ -4,6 +4,7 @@ public final class SkillRecord { public static final String LOCATION_APP = "app"; public static final String LOCATION_PROJECT = "project"; public static final String LOCATION_SSH = "ssh"; + public static final String LOCATION_IPC = "ipc"; private final String id; private final String name; @@ -80,6 +81,9 @@ public String getLocationLabel() { if (LOCATION_SSH.equals(location)) { return "SSH ~/.linecode/skills"; } + if (LOCATION_IPC.equals(location)) { + return "IPC ~/.linecode/skills"; + } return "App .linecode/skills"; } @@ -90,6 +94,9 @@ public static String normalizeLocation(String value) { if (LOCATION_SSH.equals(value)) { return LOCATION_SSH; } + if (LOCATION_IPC.equals(value)) { + return LOCATION_IPC; + } return LOCATION_APP; } } 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 a823d237..277092d9 100644 --- a/data/src/main/java/cn/lineai/data/repository/ExtensionStore.java +++ b/data/src/main/java/cn/lineai/data/repository/ExtensionStore.java @@ -95,6 +95,16 @@ public interface ExtensionStore { */ SkillRecord installSkillFromSkillHub(String homePath, String location, String slug, String version) throws Exception; + /** + * 通过已连接的 SSH 工作区安装 Skill。 + */ + SkillRecord installSkillFromSkillHubSsh(String location, String slug, String version) throws Exception; + + /** + * 通过已绑定的 IPC 终端工作区安装 Skill。 + */ + SkillRecord installSkillFromSkillHubIpc(String location, String slug, String version) throws Exception; + /** * 设置 Skill 启用状态。 */