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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Modal } from "@/components/modal"
type CreateRepoDialogProps = {
userId: string
onCreated?: (repo: Repository) => void
showSyncOption?: boolean
}

export function CreateRepoDialog(props: CreateRepoDialogProps) {
Expand Down Expand Up @@ -173,27 +174,29 @@ export function CreateRepoDialog(props: CreateRepoDialogProps) {
<option value="public">{language.t("store.capabilityDialog.visibility.public")}</option>
</select>
</div>
<div class="modal-field" style={{ flex: "1" }}>
<label class="modal-label">
{language.t("store.repoDialog.field.repoType")}
</label>
<div class="modal-toggle-group">
<button
type="button"
class={`modal-toggle ${store.repoType === "normal" ? "on" : ""}`}
onClick={() => setStore("repoType", "normal")}
>
{language.t("store.repoDialog.repoType.normal")}
</button>
<button
type="button"
class={`modal-toggle ${store.repoType === "sync" ? "on" : ""}`}
onClick={() => setStore("repoType", "sync")}
>
{language.t("store.repoDialog.repoType.sync")}
</button>
{props.showSyncOption !== false && (
<div class="modal-field" style={{ flex: "1" }}>
<label class="modal-label">
{language.t("store.repoDialog.field.repoType")}
</label>
<div class="modal-toggle-group">
<button
type="button"
class={`modal-toggle ${store.repoType === "normal" ? "on" : ""}`}
onClick={() => setStore("repoType", "normal")}
>
{language.t("store.repoDialog.repoType.normal")}
</button>
<button
type="button"
class={`modal-toggle ${store.repoType === "sync" ? "on" : ""}`}
onClick={() => setStore("repoType", "sync")}
>
{language.t("store.repoDialog.repoType.sync")}
</button>
</div>
</div>
</div>
)}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ const TAG_COLOR_BY_CLASS = {
let highlighter: Awaited<ReturnType<typeof createHighlighter>> | undefined

export function getInstallCommand(item: CapabilityItem) {
// Prefer metadata.install for plugin items (e.g. zip_download instructions)
const install = (item.metadata as Record<string, any> | undefined)?.install
if (install?.method === "zip_download" && Array.isArray(install.commands)) {
return install.commands.join("\n")
}
const registry = item.repoName || "public"
return `cs plugin add ${item.itemType} ${registry}/${item.slug}`
}
Expand Down Expand Up @@ -803,6 +808,43 @@ export default function ItemDetailContent(props: ItemDetailContentProps) {
})()}
</Show>

<Show when={data().itemType === "plugin"}>
<div class="space-y-2 rounded-[var(--native-radius-md)] border border-border-weak-base bg-bg-muted/40 p-3">
<div
class="text-xs"
style={{
color: "color-mix(in srgb, var(--native-muted) 70%, var(--native-panel))",
"font-weight": 700,
}}
>
{language.t("store.detail.localInstall") || "本地安装"}
</div>
<a
href={`/api/plugins/${data().slug}/download`}
class="inline-flex w-full items-center justify-center gap-1.5 rounded-lg border border-border-weak-base px-3 py-2 text-12-regular text-text-weak transition-colors hover:bg-bg-muted hover:text-text-strong"
download={data().slug + ".zip"}
>
<LocalIcon name="download" size="small" />
<span>下载 ZIP</span>
</a>
<pre class="thin-scrollbar overflow-x-auto rounded-lg bg-bg-muted p-2 text-[11px] leading-4 font-mono text-text-weak">
{getInstallCommand(data())}
</pre>
<button
onClick={() => {
if (!item()) return
void navigator.clipboard.writeText(getInstallCommand(item()!))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
class="inline-flex w-full items-center justify-center gap-1.5 rounded-lg border border-border-weak-base px-3 py-2 text-12-regular text-text-weak transition-colors hover:bg-bg-muted hover:text-text-strong"
>
<Icon name={copied() ? "check" : "copy"} size="small" />
<span>{copied() ? (language.t("store.detail.copied") || "已复制") : (language.t("store.detail.copyInstallCommand") || "复制安装命令")}</span>
</button>
</div>
</Show>

<div>
<div class="flex items-center justify-between gap-4">
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { pluginApi, type CapabilityItem } from "../lib/api"
import { Modal } from "@/components/modal"

type Props = {
repoId: string
onUploaded?: (item: CapabilityItem) => void
}

export function UploadPluginDialog(props: Props) {
const dialog = useDialog()
const language = useLanguage()
const [store, setStore] = createStore({
repoId: props.repoId,
file: null as File | null,
uploading: false,
progress: 0,
error: "",
dragOver: false,
isBuiltIn: false,
})

const validateFile = (file: File): string | null => {
if (!file.name.toLowerCase().endsWith(".zip")) {
return language.t("store.uploadPlugin.error.notZip") || "请上传 .zip 格式的压缩包"
}
if (file.size > 50 * 1024 * 1024) {
return language.t("store.uploadPlugin.error.tooLarge") || "文件超过 50MB 限制"
}
return null
}

const handleFileSelect = (file: File) => {
const err = validateFile(file)
if (err) {
setStore("error", err)
setStore("file", null)
return
}
setStore("file", file)
setStore("error", "")
}

const handleDrop = (e: DragEvent) => {
e.preventDefault()
setStore("dragOver", false)
if (e.dataTransfer?.files.length) {
handleFileSelect(e.dataTransfer.files[0])
}
}

const handleSubmit = async (e: Event) => {
e.preventDefault()
if (!store.file || store.uploading) return

setStore("uploading", true)
setStore("error", "")
setStore("progress", 0)

try {
const item = await pluginApi.upload(store.repoId, store.file, store.isBuiltIn, (p) => {
setStore("progress", p)
})
showToast({
title: language.t("store.uploadPlugin.success") || "Plugin 上传成功",
})
props.onUploaded?.(item)
dialog.close()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setStore("error", message)
showToast({
variant: "error",
title: language.t("store.uploadPlugin.failed") || "上传失败",
description: message,
})
} finally {
setStore("uploading", false)
}
}

return (
<form onSubmit={handleSubmit}>
<Modal
title={language.t("store.uploadPlugin.title") || "上传 Plugin"}
maxWidth="520px"
footer={
<>
<button class="modal-btn modal-btn-ghost" type="button" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</button>
<button
class="modal-btn modal-btn-primary"
type="submit"
disabled={store.uploading || !store.file}
>
{store.uploading
? `${language.t("common.uploading") || "上传中"} ${Math.round(store.progress * 100)}%`
: language.t("store.uploadPlugin.submit") || "上传"}
</button>
</>
}
>
<div class="space-y-4">
{/* Drag & Drop area */}
<div
class={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
store.dragOver
? "border-[var(--native-primary)] bg-[color-mix(in_srgb,var(--native-primary)_8%,transparent)]"
: "border-border-weak-base"
}`}
onDragOver={(e) => { e.preventDefault(); setStore("dragOver", true) }}
onDragLeave={() => setStore("dragOver", false)}
onDrop={handleDrop}
>
<Show
when={store.file}
fallback={
<>
<Icon name="cloud-upload" class="mx-auto mb-2 text-text-weak" />
<p class="text-12-regular text-text-weak">
{language.t("store.uploadPlugin.dragHint") || "拖拽文件到此处,或"}
<label class="cursor-pointer text-[var(--native-primary)] hover:underline">
{language.t("store.uploadPlugin.clickSelect") || "点击选择"}
<input
type="file"
accept=".zip"
class="hidden"
onChange={(e) => {
const f = e.currentTarget.files?.[0]
if (f) handleFileSelect(f)
}}
/>
</label>
</p>
<p class="mt-1 text-[11px] text-text-weak">
{language.t("store.uploadPlugin.sizeHint") || "仅支持 .zip,最大 50MB"}
</p>
</>
}
>
<div class="flex items-center justify-center gap-2">
<Icon name="folder" class="text-text-strong" />
<span class="text-12-regular text-text-strong">{store.file!.name}</span>
<button
type="button"
class="text-text-weak hover:text-text-strong"
onClick={() => setStore("file", null)}
>
<Icon name="close" size="small" />
</button>
</div>
</Show>
</div>

{/* Built-in checkbox */}
<label class="flex cursor-pointer items-center gap-2 text-sm text-[var(--native-foreground)]">
<input
type="checkbox"
checked={store.isBuiltIn}
onChange={(e) => setStore("isBuiltIn", e.currentTarget.checked)}
class="accent-[var(--native-primary)]"
/>
{language.t("store.uploadPlugin.isBuiltIn") || "内置 Plugin"}
</label>

{/* Progress bar */}
<Show when={store.uploading}>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-bg-muted">
<div
class="h-full rounded-full bg-[var(--native-primary)] transition-all"
style={{ width: `${store.progress * 100}%` }}
/>
</div>
</Show>

{/* Error */}
<Show when={store.error}>
<p class="text-12-regular text-[var(--native-error)]">{store.error}</p>
</Show>
</div>
</Modal>
</form>
)
}
45 changes: 45 additions & 0 deletions packages/app-ai-native/src/pages/store/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,3 +1280,48 @@ export const updateApi = {
return json?.data ?? json
},
}

export const pluginApi = {
upload: (repoId: string, file: File, isBuiltIn?: boolean, onProgress?: (p: number) => void) => {
return new Promise<CapabilityItem>((resolve, reject) => {
const form = new FormData()
form.append("repo_id", repoId)
form.append("file", file)
if (isBuiltIn) {
form.append("is_builtin", "true")
}

const xhr = new XMLHttpRequest()
xhr.open("POST", `${API_BASE}/api/plugins/upload`)
xhr.withCredentials = true

if (onProgress) {
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress(e.loaded / e.total)
}
})
}

xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
let err: any
try {
err = JSON.parse(xhr.responseText)
} catch {
err = { error: xhr.statusText }
}
reject(new Error(err.error || err.message || `Request failed: ${xhr.status}`))
}
})
xhr.addEventListener("error", () => reject(new Error("Network error")))
xhr.send(form)
})
},
listBuiltin: (page = 1, pageSize = 20) =>
apiFetch<{ items: CapabilityItem[]; total: number; page: number; pageSize: number }>(
`/api/plugins/builtin?page=${page}&pageSize=${pageSize}`,
),
}
Loading
Loading