diff --git a/.agents/skills/ipollowork-video-studio/references/video-compose.md b/.agents/skills/ipollowork-video-studio/references/video-compose.md index b10fff550..016250d7d 100644 --- a/.agents/skills/ipollowork-video-studio/references/video-compose.md +++ b/.agents/skills/ipollowork-video-studio/references/video-compose.md @@ -6,6 +6,8 @@ Use the saved storyboard, current entry and design/media/audio constraints. Befo ## Recipes and components +Keep inspection bounded to the current video project and the selected component directories. Use the catalog's variables, input rules and examples, then `video_component_read` / `video_component_write` for structured content; do not grep minified component scripts, cached tool output, or the whole runtime data directory to rediscover those contracts. Reuse action schemas already returned in this task instead of delegating schema lookup. After a repeated identical install/check failure, repair the reported fields or report the concrete blocker; do not repeat the same request or inspect engine internals in a production task. A runtime-review 503 is an unavailable preview service: retry once after Studio is ready, then return the saved draft and the blocker. + Reuse known choices/catalogs. Initial/structural assembly reads needed `core-v1-index.md`, `core-v1-video/catalog.md`, `core-v1-video/motion/catalog.md` metadata and selected `motion/component-map.md` overlays. Shortlist at most three scene bodies and three mapped components. Layouts supply spatial slots; selected recipes implement the content's temporal actions, not responsive website/PPT roots. Use `media/video_recipe_catalog` for locally installable native and Shotcraft recipes; query missing needs/`recipeCategories` once (`limit <=20`, paginate missing results only). Match intent/useWhen/avoidWhen, authorized inputs, capacity, event/reading order, landing and genre energy before appearance. Read selected local `recipe.md`, manifest and actual implementation before mounting; retain its working motion rather than rebuilding cards with entrance effects. Imports need exact card/variant and pinned source/preview; unported references/hosted links are not executable auditions. Missing cards require the actual manifest and disclosure. diff --git a/AGENTS.md b/AGENTS.md index 3e0d7ad83..6b9e091dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ Feature work starts with the demo, not a PRD: ### Bundled social plugins -Maintain Xiaohongshu and Douyin in `examples/plugin-packages/xiaohongshu-ops` and `examples/plugin-packages/douyin-ops`. These directories are the authoritative source; the sibling repositories are recovery copies only. Do not push or publish plugin changes unless the user explicitly requests it. Keep the stable plugin IDs and user data paths so accounts, drafts, credentials and browser profiles survive updates. After each change, run the plugin package's own checks and tests, then verify installation or update through the host catalog and the affected fraimz flow. Development uses these local sources (closed packages from `dist/package`) and refreshes them on restart. Production desktop builds ship only internal `reference-context`; user plugins are independently signed cloud downloads. The catalog must not depend on `~/.ipollowork/local-plugin-packages`. +Maintain Xiaohongshu and Douyin in `examples/plugin-packages/xiaohongshu-ops` and `examples/plugin-packages/douyin-ops`. These directories are the authoritative source; the sibling repositories are recovery copies only. Do not push or publish plugin changes unless the user explicitly requests it. Keep the stable plugin IDs and user data paths so accounts, drafts, credentials and browser profiles survive updates. After each change, run the plugin package's own checks and tests, then verify installation or update through the host catalog and the affected fraimz flow. Development uses these local sources (closed packages from `dist/package`) and refreshes them on restart. Production desktop builds ship only internal `reference-context`. Personal manages the available local collection and installed/imported plugins; Public uses the online plugin API. Each view supports install, update from its own source and uninstall through the same installed-package lifecycle. The catalog must not depend on `~/.ipollowork/local-plugin-packages`. ### Mandatory AI Code Gate diff --git a/apps/app/package.json b/apps/app/package.json index 99d7672c7..733b642fb 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/app", "private": true, - "version": "0.50.19", + "version": "0.50.23", "type": "module", "scripts": { "test": "bun test --isolate tests/", diff --git a/apps/app/public/ext-plugin-workshop.svg b/apps/app/public/ext-plugin-workshop.svg new file mode 100644 index 000000000..ef6650ad5 --- /dev/null +++ b/apps/app/public/ext-plugin-workshop.svg @@ -0,0 +1,23 @@ + + + + + + + diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index 06dcce709..73eb03d8f 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -64,10 +64,12 @@ img[src$="/ext-short-video.png"] { /* Keep monochrome plugin marks readable on both dark and light icon tiles. */ :is(.dark, [data-theme="dark"]) img:is( + [src$="/ext-image-studio.png"], [src$="/ext-xiaohongshu.png"], [src$="/ext-douyin.png"], [src$="/ext-wechat-channels.png"], [src$="/ext-operation-recorder.png"], + [src$="/ext-plugin-workshop.svg"], [src$="/ext-onto.png"], [src$="/ext-short-video.png"] ) { diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index eedc406b7..76443d64d 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -396,7 +396,7 @@ export async function desktopFetchViaMain( let url: string; let method: string | undefined; let headers: Record | undefined; - let body: string | undefined; + let body: string | ArrayBuffer | undefined; if (typeof Request !== "undefined" && input instanceof Request) { url = input.url; @@ -416,7 +416,11 @@ export async function desktopFetchViaMain( body = typeof init?.body === "string" ? init.body : undefined; } - const result = await invokeElectronHelper("__fetch", url, { method, headers, body, timeoutMs, responseType }); + if (init?.body instanceof Blob) body = await init.body.arrayBuffer(); + else if (init?.body instanceof ArrayBuffer) body = init.body; + init?.signal?.throwIfAborted(); + const result = await invokeElectronHelper("__fetch", url, { method, headers, body, timeoutMs, responseType, ...(init?.redirect === "error" ? { redirect: "error" } : {}) }); + init?.signal?.throwIfAborted(); if (responseType === "arrayBuffer" && typeof result.body === "string") { throw new Error("desktop_binary_fetch_requires_restart"); } diff --git a/apps/app/src/app/lib/enterprise-client.ts b/apps/app/src/app/lib/enterprise-client.ts new file mode 100644 index 000000000..64d43a292 --- /dev/null +++ b/apps/app/src/app/lib/enterprise-client.ts @@ -0,0 +1,175 @@ +import { skillDetailSchema } from "@ipollowork/types/skills"; +import type { EnterpriseResourceType } from "@ipollowork/types/enterprise"; +import type { EnterpriseSkillContext } from "@ipollowork/types/skills"; +import { + enterpriseAccessSchema, enterpriseDepartmentSchema, enterpriseMemberSchema, + enterpriseMessageSchema, enterprisePageSchema, enterpriseShareInputSchema, + enterpriseSubmissionSchema, + enterpriseManagedResourceSchema, + canManageEnterpriseResource, + enterpriseOkSchema, enterpriseCreatedSchema, enterpriseSubmissionsResponseSchema, enterprisePendingImportSchema, + type EnterpriseAccess, type EnterpriseShareInput, +} from "@ipollowork/types/enterprise"; +import { desktopFetchViaMain, desktopFetchBinaryViaMain } from "./desktop"; +import { isDesktopRuntime } from "./runtime-env"; +import type { EnterpriseConnection } from "./enterprise-connections"; +import { serviceErrorMessage } from "@ipollowork/types/provider-errors"; + +export class EnterpriseRequestError extends Error { + constructor(public readonly code: string, public readonly status: number) { super(code); } +} + +export function enterpriseErrorMessage(error: unknown): string { + const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : error instanceof Error ? error.message : ""; + if (code.startsWith("provider_")) return serviceErrorMessage(error); + if (code === "internal_error") return "操作未完成,请稍后重试。"; + if (code === "enterprise_summary_model_required") return "请先为当前项目选择一个已连接的模型。"; + if (code === "enterprise_summary_model_unavailable") return "当前模型未连接或不可用,请重新选择模型后生成总结。"; + if (code === "enterprise_share_empty") return "会话中暂无可用于总结的已完成消息。"; + if (code === "enterprise_summary_failed") return "当前模型未能生成总结,请稍后重试或手动填写总结。"; + if (code === "enterprise_summary_invalid") return "模型未返回有效的总结,请重试或手动填写总结。"; + if (code === "enterprise_context_required" || code === "enterprise_context_mismatch") return "请在当前企业中选择一个项目后再分享会话。"; + if (code === "enterprise_session_expired" || code === "unauthorized") return "企业登录已过期,请重新连接企业。"; + if (code === "enterprise_member_inactive" || code === "MEMBER_SUSPENDED") return "当前企业成员已停用,请联系企业管理员。"; + if (code === "forbidden") return "当前企业账号没有执行此操作的权限。"; + if (code === "revision_conflict") return "权限已被其他管理员修改,请刷新后重新分配。"; + if (code === "enterprise_feature_unavailable" || (error instanceof EnterpriseRequestError && [404, 405, 501].includes(error.status))) return "企业服务暂不支持此功能,请联系管理员升级。"; + if (code === "enterprise_invalid_response") return "企业服务返回的数据不完整,请重试或联系管理员。"; + if (code === "enterprise_selection_limit") return "单次最多选择 500 人,请缩小部门或搜索范围。"; + if (["invalid_skill_manifest", "invalid_skill_content", "skill_entry_missing"].includes(code)) return "技能包缺少有效清单或 SKILL.md,请检查名称、描述和配套文件后重新导出。"; + if (code === "invalid_skill_markdown") return "技能 MD 开头需包含有效的 name、description;版本号可选,默认 1.0.0。"; + if (code === "skill_markdown_too_large") return "单个技能 MD 不能超过 1 MB。"; + if (code === "skill_package_type_mismatch") return "插件和独立技能使用不同的包格式,请选择 .ipollowork-skill 技能包。"; + if (code === "unsafe_skill_file") return "技能包包含不安全路径、私有配置或不支持的文件。"; + if (code === "invalid_extension_manifest") return "插件清单格式或版本不受支持,请在插件工坊检查后重新导出,或联系管理员升级企业服务。"; + if (code === "extension_manifest_missing") return "插件包根目录缺少 ipollowork.plugin.json,请从插件工坊重新导出。"; + if (code === "extension_file_missing") return "插件包缺少清单中声明的文件,请在插件工坊检查后重新导出。"; + if (code === "invalid_template_manifest" || code === "template_manifest_missing" || code === "template_file_missing") return "模板包的清单或资源文件不完整,请检查后重新导出。"; + if (code === "invalid_template_package" || code === "duplicate_template_file" || code === "unsupported_zip_compression") return "资源包压缩格式无效或文件路径异常,请重新导出后提交。"; + if (code === "unsafe_template_file" || code === "unsupported_template_file") return "资源包包含不支持或不安全的文件,请检查包内容后重新导出。"; + if (code === "template_package_too_large" || code === "invalid_artifact_size" || code === "payload_too_large") return "资源包为空或超过大小、文件数量限制,请精简后重新提交。"; + if (code === "invalid_artifact_type") return "资源包类型不正确,请选择模板包、.ipollowork-plugin 插件包或 .ipollowork-skill 技能包。"; + if (code === "resource_version_already_exists" || code === "resource_version_not_newer") return "该版本已存在或版本号未递增,请修改版本号后重新导出。"; + if (code === "reserved_resource_id") return "资源 ID 使用了内置保留名称,请修改后重新导出。"; + return "企业服务请求失败,请检查连接后重试。"; +} + +export function enterpriseSkillContext(connection: EnterpriseConnection): EnterpriseSkillContext { + return { enterpriseId: connection.id, origin: connection.origin, memberId: connection.membership.id, token: connection.session.token }; +} + +export function enterpriseIdentity(connection: EnterpriseConnection): string { + return `${connection.id}:${connection.membership.id}`; +} + +export const enterpriseShareSessionEvent = "ipollowork:enterprise-share-session"; +export const enterpriseImportMessageEvent = "ipollowork:enterprise-import-message"; +const PENDING_IMPORT_KEY = "ipollowork:enterprise-pending-import"; +export function requestEnterpriseSessionShare(workspaceId: string, sessionId: string) { + window.dispatchEvent(new CustomEvent(enterpriseShareSessionEvent, { detail: { workspaceId, sessionId } })); +} +export function requestEnterpriseMessageImport(connection: EnterpriseConnection, messageId: string) { + window.sessionStorage.setItem(PENDING_IMPORT_KEY, JSON.stringify({ identity: enterpriseIdentity(connection), messageId })); + window.dispatchEvent(new CustomEvent(enterpriseImportMessageEvent)); +} +export function takeEnterpriseMessageImport(connection: EnterpriseConnection): string | null { + const raw = window.sessionStorage.getItem(PENDING_IMPORT_KEY); + if (!raw) return null; + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { window.sessionStorage.removeItem(PENDING_IMPORT_KEY); return null; } + const request = enterprisePendingImportSchema.safeParse(parsed); + if (!request.success || request.data.identity !== enterpriseIdentity(connection)) return null; + window.sessionStorage.removeItem(PENDING_IMPORT_KEY); + return request.data.messageId; +} + +export function isEnterpriseConnectionActive(connection: EnterpriseConnection): boolean { + return (!connection.membership.status || connection.membership.status === "active") + && Date.parse(connection.session.expiresAt) > Date.now() + && Boolean(connection.session.token.trim()); +} + +export function canManageEnterprise(connection: EnterpriseConnection, resourceType?: EnterpriseResourceType): boolean { + return isEnterpriseConnectionActive(connection) + && canManageEnterpriseResource(connection.membership.role, resourceType); +} + +export async function enterpriseRequest( + connection: EnterpriseConnection, path: string, schema: { safeParse: (value: unknown) => { success: true; data: T } | { success: false } }, + init: RequestInit = {}, fetcher?: typeof fetch, +): Promise { + if (!path.startsWith("/api/v1/") || path.includes("..") || path.includes("\\") || path.includes("#")) throw new Error("enterprise_invalid_path"); + const readingMembership = path === "/api/v1/membership" && (!init.method || init.method === "GET"); + if (connection.membership.status && connection.membership.status !== "active" && !readingMembership) throw new EnterpriseRequestError("enterprise_member_inactive", 403); + if (!Number.isFinite(Date.parse(connection.session.expiresAt)) || Date.parse(connection.session.expiresAt) <= Date.now()) throw new EnterpriseRequestError("enterprise_session_expired", 401); + const timeout = AbortSignal.timeout(init.body instanceof File ? 120_000 : 15_000); + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${connection.session.token}`); + const requestFetcher = fetcher ?? (isDesktopRuntime() + ? (input: RequestInfo | URL, options?: RequestInit) => init.body instanceof File + ? desktopFetchBinaryViaMain(input, options, 120_000) + : desktopFetchViaMain(input, options, 15_000) + : globalThis.fetch); + signal.throwIfAborted(); + const response = await requestFetcher(`${connection.origin}${path}`, { ...init, headers, signal, redirect: "error" }); + signal.throwIfAborted(); + const value: unknown = await response.json().catch(() => null); + signal.throwIfAborted(); + if (!response.ok) { + const code = typeof value === "object" && value !== null && "error" in value && typeof value.error === "string" ? value.error : `enterprise_server_${response.status}`; + throw new EnterpriseRequestError(code, response.status); + } + const parsed = schema.safeParse(value); + if (!parsed.success) throw new Error("enterprise_invalid_response"); + return parsed.data; +} + +function query(input: { search?: string; departmentId?: string | null; includeDescendants?: boolean; cursor?: string | null }) { + const value = new URLSearchParams({ limit: "50" }); + if (input.search?.trim()) value.set("search", input.search.trim().slice(0, 160)); + if (input.departmentId) value.set("departmentId", input.departmentId); + if (input.includeDescendants) value.set("includeDescendants", "true"); + if (input.cursor) value.set("cursor", input.cursor); + return value.toString(); +} +const ok = enterpriseOkSchema; +export const enterpriseApi = { + skill: (connection: EnterpriseConnection, resourceId: string, version: string) => enterpriseRequest(connection, `/api/v1/resources/${encodeURIComponent(resourceId)}?version=${encodeURIComponent(version)}`, skillDetailSchema), + resources: (connection: EnterpriseConnection, type: EnterpriseResourceType, cursor?: string | null, search?: string) => + enterpriseRequest(connection, `/api/v1/resources?type=${type}&view=manage&${query({ cursor, search })}`, enterprisePageSchema(enterpriseManagedResourceSchema)), + review: (connection: EnterpriseConnection, resourceId: string, version: string, decision: "approved" | "rejected", note: string) => + enterpriseRequest(connection, `/api/v1/resources/${encodeURIComponent(resourceId)}/versions/${encodeURIComponent(version)}/review`, ok, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision, note }), + }), + archive: (connection: EnterpriseConnection, resourceId: string) => + enterpriseRequest(connection, `/api/v1/resources/${encodeURIComponent(resourceId)}`, ok, { + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "archived" }), + }), + departments: (connection: EnterpriseConnection, cursor?: string | null, signal?: AbortSignal) => + enterpriseRequest(connection, `/api/v1/departments?${query({ cursor })}`, enterprisePageSchema(enterpriseDepartmentSchema), { signal }), + members: (connection: EnterpriseConnection, input: { search?: string; departmentId?: string | null; includeDescendants?: boolean; cursor?: string | null }, signal?: AbortSignal) => + enterpriseRequest(connection, `/api/v1/members?${query(input)}`, enterprisePageSchema(enterpriseMemberSchema), { signal }), + messages: (connection: EnterpriseConnection, cursor?: string | null, signal?: AbortSignal) => + enterpriseRequest(connection, `/api/v1/messages?${query({ cursor })}`, enterprisePageSchema(enterpriseMessageSchema), { signal }), + message: (connection: EnterpriseConnection, id: string, signal?: AbortSignal) => + enterpriseRequest(connection, `/api/v1/messages/${encodeURIComponent(id)}`, enterpriseMessageSchema, { signal }), + messageAction: (connection: EnterpriseConnection, id: string, action: "read" | "accept" | "ignore") => + enterpriseRequest(connection, `/api/v1/messages/${encodeURIComponent(id)}/${action}`, ok, { method: "POST" }), + share: (connection: EnterpriseConnection, input: EnterpriseShareInput) => + enterpriseRequest(connection, "/api/v1/session-shares", enterpriseCreatedSchema, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(enterpriseShareInputSchema.parse(input)), + }), + access: (connection: EnterpriseConnection, resourceId: string) => + enterpriseRequest(connection, `/api/v1/resources/${encodeURIComponent(resourceId)}/access`, enterpriseAccessSchema), + setAccess: (connection: EnterpriseConnection, resourceId: string, input: EnterpriseAccess) => + enterpriseRequest(connection, `/api/v1/resources/${encodeURIComponent(resourceId)}/access`, enterpriseAccessSchema, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(enterpriseAccessSchema.parse(input)), + }), + submissions: (connection: EnterpriseConnection, type: EnterpriseResourceType) => + enterpriseRequest(connection, `/api/v1/${type}-submissions`, enterpriseSubmissionsResponseSchema), + submit: (connection: EnterpriseConnection, type: EnterpriseResourceType, file: File) => + enterpriseRequest(connection, `/api/v1/${type}-submissions`, enterpriseSubmissionSchema, { + method: "POST", headers: { "Content-Type": type === "skill" && file.name.toLowerCase().endsWith(".md") ? "text/markdown; charset=utf-8" : "application/zip" }, body: file, + }), +}; diff --git a/apps/app/src/app/lib/enterprise-connections.ts b/apps/app/src/app/lib/enterprise-connections.ts index c02872147..546ec1633 100644 --- a/apps/app/src/app/lib/enterprise-connections.ts +++ b/apps/app/src/app/lib/enterprise-connections.ts @@ -1,8 +1,12 @@ +import type { EnterpriseResourceType } from "@ipollowork/types/enterprise"; +export type { EnterpriseResourceType } from "@ipollowork/types/enterprise"; import { comparePluginPackageVersions } from "@ipollowork/types/plugins"; +import { enterpriseFeaturesSchema, enterpriseMemberSchema, enterpriseMemberRoleSchema, enterpriseMembershipResponseSchema, type EnterpriseFeatures, type EnterpriseMember } from "@ipollowork/types/enterprise"; +import { enterpriseRequest } from "./enterprise-client"; import { readPluginPackageArchive } from "./plugin-package-archive"; import type { iPolloWorkPluginPackageItem, iPolloWorkServerClient } from "./ipollowork-server"; import { desktopFetchBinaryViaMain, desktopFetchViaMain } from "./desktop"; -import { readDenSettings } from "./den"; +import { normalizeDenBaseUrl, readDenSettings } from "./den"; import { isDesktopRuntime } from "./runtime-env"; import { IPOLLOWORK_PACKAGE_EXTENSION, @@ -12,6 +16,7 @@ import { } from "@ipollowork/types/templates"; const ENTERPRISE_CONNECTIONS_KEY = "ipollowork.enterprise-connections.v1"; +let connectionAccount: { storageKey: string; baseUrl: string; token: string } | null = null; const ENTERPRISE_CONNECTION_LIMIT = 12; const ENTERPRISE_TIMEOUT_MS = 12_000; const ENTERPRISE_DOWNLOAD_TIMEOUT_MS = 120_000; @@ -26,7 +31,7 @@ export const enterpriseConnectionsChangedEvent = "ipollowork:enterprise-connecti type JsonRecord = Record; -export type EnterpriseMemberRole = "owner" | "admin" | "member"; +export type EnterpriseMemberRole = EnterpriseMember["role"]; export type EnterpriseServer = { id: string; @@ -36,12 +41,15 @@ export type EnterpriseServer = { logoUrl: string | null; accent: "blue" | "neutral"; authMode: "bootstrap" | "ipollo_oidc"; + features?: EnterpriseFeatures; }; export type EnterpriseConnection = EnterpriseServer & { membership: { id: string; role: EnterpriseMemberRole; + status?: EnterpriseMember["status"]; + departmentId?: string | null; }; session: { token: string; @@ -49,7 +57,7 @@ export type EnterpriseConnection = EnterpriseServer & { }; }; -export type EnterpriseResourceType = "template" | "extension"; + export type EnterpriseResource = { id: string; @@ -83,6 +91,7 @@ type EnterpriseManifest = { shortName: string; logoUrl: string | null; accent: EnterpriseServer["accent"]; + features?: EnterpriseFeatures; }; function isRecord(value: unknown): value is JsonRecord { @@ -95,7 +104,8 @@ function readString(record: JsonRecord, key: string) { } function parseRole(value: unknown): EnterpriseMemberRole | null { - return value === "owner" || value === "admin" || value === "member" ? value : null; + const parsed = enterpriseMemberRoleSchema.safeParse(value); + return parsed.success ? parsed.data : null; } export function normalizeEnterpriseOrigin(value: string): string | null { @@ -137,7 +147,8 @@ function parseManifest(value: unknown): EnterpriseManifest | null { const accentValue = readString(enterprise, "accent"); const accent = accentValue === "neutral" ? "neutral" : accentValue === "blue" ? "blue" : null; if (!id.startsWith("ent_") || !name || !shortName || !accent) return null; - return { id, name, shortName, logoUrl, accent }; + const features = enterpriseFeaturesSchema.safeParse(value.features); + return { id, name, shortName, logoUrl, accent, ...(features.success ? { features: features.data } : {}) }; } function endpoint(origin: string, path: string) { @@ -206,6 +217,7 @@ export async function discoverEnterpriseConnection( logoUrl: manifest.logoUrl, accent: manifest.accent, authMode: discovery.authMode, + ...(manifest.features ? { features: manifest.features } : {}), }; } @@ -213,10 +225,23 @@ export async function refreshEnterpriseConnection( connection: EnterpriseConnection, fetcher?: typeof fetch, ): Promise { + const accountKey = connectionStorageKey(); + const storedAtStart = readEnterpriseConnections().find((item) => item.id === connection.id); const server = await discoverEnterpriseConnection(connection.origin, fetcher); if (server.id !== connection.id) throw new Error("enterprise_manifest_mismatch"); + if (accountKey !== connectionStorageKey()) return connection; const current = readEnterpriseConnections().find((item) => item.id === connection.id) ?? connection; - const refreshed = { ...current, ...server }; + let refreshed = { ...current, ...server }; + if (!server.features) delete refreshed.features; + if (server.features?.membership) { + const result = await enterpriseRequest(refreshed, "/api/v1/membership", enterpriseMembershipResponseSchema, {}, fetcher); + if (result.membership.id !== connection.membership.id) throw new Error("enterprise_manifest_mismatch"); + refreshed = { ...refreshed, membership: { id: result.membership.id, role: result.membership.role, status: result.membership.status, departmentId: result.membership.departmentId } }; + } + if (accountKey !== connectionStorageKey()) return refreshed; + const latest = readEnterpriseConnections().find((item) => item.id === connection.id); + if (storedAtStart && !latest) return refreshed; + if (latest && (latest.membership.id !== current.membership.id || latest.session.token !== current.session.token)) return latest; if ( current.name !== refreshed.name || current.shortName !== refreshed.shortName @@ -224,6 +249,8 @@ export async function refreshEnterpriseConnection( || current.accent !== refreshed.accent || current.origin !== refreshed.origin || current.authMode !== refreshed.authMode + || JSON.stringify(current.features) !== JSON.stringify(refreshed.features) + || JSON.stringify(current.membership) !== JSON.stringify(refreshed.membership) ) { saveEnterpriseConnection(refreshed); } @@ -422,7 +449,7 @@ export async function downloadEnterpriseResource( || new URL(downloadUrl).pathname.toLowerCase().endsWith(IPOLLOWORK_PACKAGE_EXTENSION); const extension = resource.type === "template" ? (canonicalPackage ? IPOLLOWORK_PACKAGE_EXTENSION : LEGACY_TEMPLATE_PACKAGE_EXTENSION).slice(1) - : ENTERPRISE_EXTENSION_PACKAGE_EXTENSION.slice(1); + : resource.type === "skill" ? "ipollowork-skill" : ENTERPRISE_EXTENSION_PACKAGE_EXTENSION.slice(1); return new File([bytes], `${resource.slug}-${resource.latestVersion.version}.${extension}`, { type: response.headers.get("content-type") || "application/octet-stream", }); @@ -439,6 +466,9 @@ export async function joinEnterpriseWithCode( if (!cloudOrigin || !cloudToken) throw new Error("cloud_signin_required"); if (!enterpriseOrigin) throw new Error("invalid_enterprise_url"); if (!/^[A-Z2-9]{5}-?[A-Z2-9]{5}$/u.test(joinCode)) throw new Error("invalid_join_code"); + const accountKey = connectionStorageKey(); + if (!accountKey || connectionAccount?.token !== cloudToken + || connectionAccount.baseUrl !== normalizeDenBaseUrl(input.cloudBaseUrl)) throw new Error("cloud_signin_required"); const authorization = { Authorization: `Bearer ${cloudToken}` }; const requestFetcher = enterpriseFetcher(fetcher); @@ -457,13 +487,14 @@ export async function joinEnterpriseWithCode( }, requestFetcher); const connection = parseJoinResult(joinPayload, discoveredServer); if (!connection) throw new Error("invalid_enterprise_join_response"); + if (accountKey !== connectionStorageKey()) throw new Error("cloud_signin_required"); saveEnterpriseConnection(connection); return connection; } function parseStoredConnection(value: unknown): EnterpriseConnection | null { if (!isRecord(value)) return null; - const server = parseManifest({ enterprise: value }); + const server = parseManifest({ enterprise: value, features: value.features }); const origin = normalizeEnterpriseOrigin(readString(value, "origin")); const authModeValue = readString(value, "authMode"); const authMode = authModeValue === "bootstrap" || authModeValue === "ipollo_oidc" ? authModeValue : null; @@ -473,7 +504,9 @@ function parseStoredConnection(value: unknown): EnterpriseConnection | null { const token = readString(value.session, "token"); const expiresAt = readString(value.session, "expiresAt"); if (!memberId || !role || !token || !expiresAt) return null; - return { ...server, origin, authMode, membership: { id: memberId, role }, session: { token, expiresAt } }; + const status = enterpriseMemberSchema.shape.status.safeParse(value.membership.status); + const departmentId = value.membership.departmentId; + return { ...server, origin, authMode, membership: { id: memberId, role, ...(status.success ? { status: status.data } : {}), ...(departmentId === null || typeof departmentId === "string" ? { departmentId } : {}) }, session: { token, expiresAt } }; } function dispatchConnectionsChanged() { @@ -481,10 +514,44 @@ function dispatchConnectionsChanged() { window.dispatchEvent(new CustomEvent(enterpriseConnectionsChangedEvent)); } +function connectionStorageKey(): string | null { + if (!connectionAccount) return null; + const settings = readDenSettings(); + return settings.authToken?.trim() === connectionAccount.token + && normalizeDenBaseUrl(settings.baseUrl) === connectionAccount.baseUrl + ? connectionAccount.storageKey : null; +} + +/** Select only after Cloud has verified the account; tokens never form the persistent cache key. */ +export function setEnterpriseConnectionAccount(account: { userId: string; baseUrl: string; token: string } | null) { + if (typeof window === "undefined") return; + if (!account) { + connectionAccount = null; + dispatchConnectionsChanged(); + return; + } + const settings = readDenSettings(); + const baseUrl = normalizeDenBaseUrl(account.baseUrl); + const userId = account.userId.trim(); + const token = account.token.trim(); + if (!baseUrl || !userId || !token || normalizeDenBaseUrl(settings.baseUrl) !== baseUrl + || settings.authToken?.trim() !== token) return; + const storageKey = `${ENTERPRISE_CONNECTIONS_KEY}:${encodeURIComponent(baseUrl)}:${encodeURIComponent(userId)}`; + if (connectionAccount?.storageKey === storageKey && connectionAccount.token === token) return; + // Adopt the pre-account format once, on the first verified session after upgrading. + const legacy = window.localStorage.getItem(ENTERPRISE_CONNECTIONS_KEY); + if (legacy && window.localStorage.getItem(storageKey) === null) window.localStorage.setItem(storageKey, legacy); + window.localStorage.removeItem(ENTERPRISE_CONNECTIONS_KEY); + connectionAccount = { storageKey, baseUrl, token }; + dispatchConnectionsChanged(); +} + export function readEnterpriseConnections(): EnterpriseConnection[] { if (typeof window === "undefined") return []; + const storageKey = connectionStorageKey(); + if (!storageKey) return []; try { - const raw = window.localStorage.getItem(ENTERPRISE_CONNECTIONS_KEY); + const raw = window.localStorage.getItem(storageKey); if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; @@ -499,9 +566,11 @@ export function readEnterpriseConnections(): EnterpriseConnection[] { export function saveEnterpriseConnection(connection: EnterpriseConnection) { if (typeof window === "undefined") return; + const storageKey = connectionStorageKey(); + if (!storageKey) throw new Error("cloud_signin_required"); const existing = readEnterpriseConnections().filter((item) => item.id !== connection.id); window.localStorage.setItem( - ENTERPRISE_CONNECTIONS_KEY, + storageKey, JSON.stringify([connection, ...existing].slice(0, ENTERPRISE_CONNECTION_LIMIT)), ); dispatchConnectionsChanged(); @@ -509,8 +578,10 @@ export function saveEnterpriseConnection(connection: EnterpriseConnection) { export function removeEnterpriseConnection(connectionId: string) { if (typeof window === "undefined") return; + const storageKey = connectionStorageKey(); + if (!storageKey) return; const remaining = readEnterpriseConnections().filter((item) => item.id !== connectionId); - window.localStorage.setItem(ENTERPRISE_CONNECTIONS_KEY, JSON.stringify(remaining)); + window.localStorage.setItem(storageKey, JSON.stringify(remaining)); dispatchConnectionsChanged(); } diff --git a/apps/app/src/app/lib/ipollowork-server.ts b/apps/app/src/app/lib/ipollowork-server.ts index 44e4920ac..0e669ac41 100644 --- a/apps/app/src/app/lib/ipollowork-server.ts +++ b/apps/app/src/app/lib/ipollowork-server.ts @@ -1,5 +1,7 @@ +import type { EnterpriseSkillContext, EnterpriseSkillInstallInput, InstalledEnterpriseSkill, SkillPackageExportInput, SkillPackageManifest } from "@ipollowork/types/skills"; import { VerifiedInboxReceiptSchema, type InboxUploadOptions } from "@ipollowork/types/reference-context"; import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client"; +import type { EnterpriseSessionMetadata, EnterpriseSnapshot, EnterpriseSnapshotImport, EnterpriseSummary } from "@ipollowork/types/enterprise"; import { serviceErrorMessage } from "@ipollowork/types/provider-errors"; import { desktopFetch } from "./desktop"; import { isDesktopRuntime } from "./runtime-env"; @@ -164,7 +166,7 @@ export type iPolloWorkSessionMessage = { parts: Part[]; }; -export type iPolloWorkSession = Session & SessionTokenMetering; +export type iPolloWorkSession = Session & SessionTokenMetering & EnterpriseSessionMetadata; export type iPolloWorkSessionSnapshot = { session: iPolloWorkSession; @@ -178,6 +180,8 @@ export type iPolloWorkSessionSnapshot = { export type iPolloWorkResourceScope = "personal" | `enterprise:${string}`; +export type StandaloneSkillPackage = iPolloWorkSkillItem & { pluginName: string | null; canExport: boolean; source: { origin: string; resourceId: string; version: string; digest: string } | null }; + export type iPolloWorkSkillItem = { name: string; path: string; @@ -1383,6 +1387,16 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/template-sessions/${encodeURIComponent(sessionId)}/adopt-video`, { token, hostToken, method: "POST", body: {}, timeoutMs: timeouts.workspaceImport }), listTemplateSessions: (workspaceId: string) => requestJson<{ items: TemplateSessionSnapshot[] }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/template-sessions`, { token, hostToken }), + summarizeSessionForEnterprise: (workspaceId: string, sessionId: string, model?: { providerID: string; modelID: string }) => + requestJson<{ item: EnterpriseSummary }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/share-summary`, { + token, hostToken, method: "POST", body: { model }, timeoutMs: 130_000, + }), + importEnterpriseSnapshot: (workspaceId: string, snapshot: EnterpriseSnapshotImport, model?: { providerID: string; modelID: string }, engineId?: string) => + requestJson<{ item: iPolloWorkSession; workspaceId: string }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/enterprise-shares/import`, { + token, hostToken, method: "POST", body: { snapshot, model, engineId }, timeoutMs: timeouts.sessionRead, + }), + getEnterpriseSnapshot: (workspaceId: string, sessionId: string) => + requestJson<{ snapshot: EnterpriseSnapshot | null }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/enterprise-share`, { token, hostToken, timeoutMs: timeouts.sessionRead }), createSession: ( workspaceId: string, title?: string, @@ -1909,6 +1923,13 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: hostToken, }); }, + listSkillPackages: (workspaceId: string) => requestJson<{ personal: StandaloneSkillPackage[]; enterprise: InstalledEnterpriseSkill[] }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages`, { token, hostToken }), + setEnterpriseSkillContext: (workspaceId: string, context: EnterpriseSkillContext | null) => requestJson<{ ok: boolean }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages/context`, { token, hostToken, method: "PUT", body: { context } }), + installEnterpriseSkill: (workspaceId: string, input: EnterpriseSkillInstallInput) => requestJson<{ item: InstalledEnterpriseSkill }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages/enterprise`, { token, hostToken, method: "POST", body: input, timeoutMs: 120000 }), + updateEnterpriseSkill: (workspaceId: string, resourceId: string, enabled: boolean | null) => requestJson<{ ok: boolean }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages/enterprise/${encodeURIComponent(resourceId)}`, { token, hostToken, method: enabled === null ? "DELETE" : "PATCH", ...(enabled === null ? {} : { body: { enabled } }) }), + getEnterpriseSkill: (workspaceId: string, resourceId: string) => requestJson<{ manifest: SkillPackageManifest; content: string; files: string[] }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages/enterprise/${encodeURIComponent(resourceId)}`, { token, hostToken }), + exportSkillPackage: (workspaceId: string, input: SkillPackageExportInput) => requestBinary(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages/export`, { token, hostToken, method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), timeoutMs: 120000 }), + importSkillPackage: (workspaceId: string, file: File, source: { origin: string; resourceId: string; digest: string }) => requestRawJson<{ name: string; version: string }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/skill-packages/import`, { token, hostToken, headers: { "Content-Type": file.name.toLowerCase().endsWith(".md") ? "text/markdown; charset=utf-8" : "application/vnd.ipollowork.skill+zip", "X-iPolloWork-Skill-Origin": source.origin, "X-iPolloWork-Skill-Id": source.resourceId, "X-iPolloWork-Skill-Digest": source.digest }, body: file, timeoutMs: 120000 }), installHubSkill: ( workspaceId: string, name: string, diff --git a/apps/app/src/app/lib/skill-catalog.ts b/apps/app/src/app/lib/skill-catalog.ts new file mode 100644 index 000000000..8f65d6ad7 --- /dev/null +++ b/apps/app/src/app/lib/skill-catalog.ts @@ -0,0 +1,47 @@ +import { publicSkillCatalogSchema, type PublicSkill } from "@ipollowork/types/skills"; +export type { PublicSkill } from "@ipollowork/types/skills"; +import { MAX_SKILL_PACKAGE_BYTES } from "@ipollowork/types/skills"; +import { readDenSettings } from "./den"; +import { desktopFetchViaMain, desktopFetchBinaryViaMain } from "./desktop"; +import { isDesktopRuntime } from "./runtime-env"; + +const fetchCatalog = (url: string, options: RequestInit, binary = false, fetcher?: typeof fetch) => { + const request: RequestInit = { ...options, signal: AbortSignal.timeout(binary ? 120000 : 15000), redirect: "error" }; + if (fetcher) return fetcher(url, request); + return isDesktopRuntime() ? binary ? desktopFetchBinaryViaMain(url, request, 120000) : desktopFetchViaMain(url, request, 15000) : fetch(url, request); +}; +async function boundedSkillResponse(response: Response, maximum: number): Promise { + if (Number(response.headers.get("content-length")) > maximum) throw new Error("技能内容超过大小限制。"); + const reader = response.body?.getReader(); if (!reader) throw new Error("技能服务返回了空内容。"); + const chunks: Uint8Array[] = []; let size = 0; + try { while (true) { const next = await reader.read(); if (next.done) break; size += next.value.length; if (size > maximum) { await reader.cancel(); throw new Error("技能内容超过大小限制。"); } chunks.push(next.value); } } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } return bytes.buffer; +} + +function publicAccess() { + const settings = readDenSettings(); + return { origin: settings.baseUrl.replace(/\/$/u, ""), headers: settings.authToken ? { Authorization: `Bearer ${settings.authToken}` } : undefined }; +} +export async function listPublicSkills(input: { search?: string; cursor?: string | null } = {}, fetcher?: typeof fetch) { + const { origin, headers } = publicAccess(); const query = new URLSearchParams({ limit: "50" }); + if (input.search?.trim()) query.set("search", input.search.trim().slice(0, 160)); if (input.cursor) query.set("cursor", input.cursor); + const response = await fetchCatalog(`${origin}/api/v1/skills?${query}`, { headers }, false, fetcher); + if ([404, 405, 501].includes(response.status)) throw new Error("公开云端尚未开放技能市场接口,接口规范已准备好。"); + if (!response.ok) throw new Error(response.status === 401 ? "请登录后查看公开技能。" : "公开技能暂时无法加载,请稍后重试。"); + const parsed = publicSkillCatalogSchema.safeParse(JSON.parse(new TextDecoder().decode(await boundedSkillResponse(response, 2 * 1024 * 1024)))); + if (!parsed.success) throw new Error("云端技能接口返回格式不符合约定,请联系云端服务管理员。"); + return parsed.data; +} +export async function downloadPublicSkill(item: PublicSkill, fetcher?: typeof fetch) { + const { origin, headers } = publicAccess(); + const response = await fetchCatalog(`${origin}/api/v1/skills/${encodeURIComponent(item.id)}/versions/${encodeURIComponent(item.latestVersion.version)}/download`, { headers }, true, fetcher); + if (!response.ok) throw new Error(response.status === 401 ? "请先登录后下载技能。" : "技能已下架或当前版本无法下载,请刷新后重试。"); + if (Number(response.headers.get("content-length")) > MAX_SKILL_PACKAGE_BYTES) throw new Error("技能包不能超过 50 MB。"); + const bytes = await boundedSkillResponse(response, MAX_SKILL_PACKAGE_BYTES); if (bytes.byteLength > MAX_SKILL_PACKAGE_BYTES) throw new Error("技能包不能超过 50 MB。"); + const digest = await skillFileDigest(bytes); + if (digest !== item.latestVersion.digest || response.headers.get("X-iPollo-Artifact-SHA256") !== digest) throw new Error("技能包摘要校验失败,未安装。"); + return { file: new File([bytes], `${item.name}-${item.latestVersion.version}.ipollowork-skill`, { type: "application/vnd.ipollowork.skill+zip" }), source: { origin, resourceId: item.id, digest } }; +} +export async function skillFileDigest(bytes: ArrayBuffer) { + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (value) => value.toString(16).padStart(2, "0")).join(""); +} diff --git a/apps/app/src/app/lib/work-context.ts b/apps/app/src/app/lib/work-context.ts index 4144d3f39..98d7f9bb0 100644 --- a/apps/app/src/app/lib/work-context.ts +++ b/apps/app/src/app/lib/work-context.ts @@ -10,8 +10,10 @@ import { } from "./desktop"; import { readEnterpriseConnections, + setEnterpriseConnectionAccount, type EnterpriseConnection, } from "./enterprise-connections"; +import { isEnterpriseConnectionActive } from "./enterprise-client"; import { createiPolloWorkServerClient, } from "./ipollowork-server"; @@ -104,7 +106,7 @@ export function readActiveEnterpriseConnection(): EnterpriseConnection | null { const contextId = readActiveWorkContextId(); if (contextId === PERSONAL_WORK_CONTEXT_ID) return null; const enterpriseId = contextId.slice("enterprise:".length); - return readEnterpriseConnections().find((connection) => connection.id === enterpriseId) ?? null; + return readEnterpriseConnections().find((connection) => connection.id === enterpriseId && isEnterpriseConnectionActive(connection)) ?? null; } export function workspaceBelongsToWorkContext( @@ -171,6 +173,11 @@ function commitActiveContext(contextId: WorkContextId) { window.dispatchEvent(new CustomEvent(workContextChangedEvent, { detail: contextId })); } +export function clearEnterpriseLoginState() { + setEnterpriseConnectionAccount(null); + commitActiveContext(PERSONAL_WORK_CONTEXT_ID); +} + async function localServerClient() { const info = await ipolloworkServerInfo(); const baseUrl = info.baseUrl?.trim() ?? ""; @@ -242,6 +249,9 @@ export async function activatePersonalWorkContext(): Promise { } export async function activateEnterpriseWorkContext(connection: EnterpriseConnection): Promise { + if (!isEnterpriseConnectionActive(connection) || !readEnterpriseConnections().some((item) => + item.id === connection.id && item.membership.id === connection.membership.id + && item.session.token === connection.session.token)) throw new Error("enterprise_signin_required"); const contextId = enterpriseWorkContextId(connection.id); let committed = false; dispatchSwitch("start", contextId); diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 14c9f3fed..42f1a713b 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -345,12 +345,12 @@ export default { "plugin_library.marketplace_description": "Discover reviewed capability packages. Install once and use them in every project.", "plugin_library.personal": "Personal", "plugin_library.personal_title": "Personal plugins", - "plugin_library.personal_description": "Manage installed or imported plugins and update them from the cloud here. Compatible capabilities are shared across projects.", + "plugin_library.personal_description": "Browse the local plugin collection. Install, update, uninstall, or import packages from this computer.", "plugin_library.browse_public": "Browse public plugins", "plugin_platform.error.cloud_package_mismatch": "The downloaded package does not match the cloud version. Installation stopped and the existing plugin was preserved. Try again later.", - "plugin_library.check_updates": "Check for updates", + "plugin_library.check_updates": "Check local updates", "plugin_library.update_check_failed": "Cloud updates could not be checked. Installed plugins remain available.", - "plugin_library.personal_empty": "No plugins installed. Download one from Public to add it here.", + "plugin_library.personal_empty": "No local plugins available. Import a plugin package from this computer.", "plugin_library.featured": "Featured", "plugin_library.all_categories": "All categories", "plugin_library.all_statuses": "All statuses", @@ -673,6 +673,11 @@ export default { "design_system.embedded.font_ibm_plex_sans": "IBM Plex Sans", "template_market.title": "Templates", "plugin_workshop.title": "Plugin Workshop", + "plugin_workshop.unavailable": "Plugin Workshop is unavailable. Install or enable it in Extensions.", + "sidebar.plugin_shortcuts": "Plugin shortcuts", + "sidebar.plugin_shortcuts_hint": "Choose plugins for the sidebar", + "sidebar.plugin_shortcuts_empty": "Install a plugin with a workbench to add a shortcut.", + "sidebar.manage_plugins": "Manage plugins", "plugin_workshop.create_session_failed": "Could not create a conversation for Plugin Workshop.", "plugin_workshop.operation_failed": "Plugin Workshop operation failed", "plugin_workshop.blank_description": "Tell AI what plugin you want to build. Studio, MCP Server, Skills, and scripts are generated into the current project's plugin directory.", @@ -1199,6 +1204,7 @@ export default { "project_overview.no_agent_presets": "No matching presets, or all have been added", "project_overview.agent_limit": "A conversation can have up to 24 agents", "project_overview.agents": "Agents", + "project_overview.new_conversations_inherit": "These are the project's default agents. New conversations inherit them; existing conversations keep their own configuration.", "project_overview.agent_name": "Name", "project_overview.agent_role": "Responsibility", "project_overview.agent_no_role": "No responsibility set", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 6d2931aaa..007120c21 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -348,12 +348,12 @@ export default { "plugin_library.marketplace_description": "发现经过审核的完整能力包;安装一次,即可在所有项目中使用。", "plugin_library.personal": "个人", "plugin_library.personal_title": "个人插件", - "plugin_library.personal_description": "仅显示已安装或导入的插件。可检查云端版本并在此更新;所有项目共享兼容能力。", + "plugin_library.personal_description": "浏览本地插件集,在此安装、更新或卸载;也可导入本机插件包。", "plugin_library.browse_public": "浏览公开插件", "plugin_platform.error.cloud_package_mismatch": "下载包与云端版本信息不一致,已停止安装,原有插件保持不变。请稍后重试。", - "plugin_library.check_updates": "检查更新", + "plugin_library.check_updates": "检查本地更新", "plugin_library.update_check_failed": "暂时无法检查云端更新,已安装插件仍可使用。", - "plugin_library.personal_empty": "还没有安装插件。到公开市场下载后,插件会显示在这里。", + "plugin_library.personal_empty": "当前没有本地插件,可导入本机插件包后使用。", "plugin_library.featured": "精选", "plugin_library.all_categories": "全部分类", "plugin_library.all_statuses": "全部状态", @@ -675,6 +675,11 @@ export default { "design_system.embedded.font_ibm_plex_sans": "IBM Plex Sans", "template_market.title": "模板", "plugin_workshop.title": "插件工坊", + "plugin_workshop.unavailable": "插件工坊未安装或已停用,请在扩展中安装或启用。", + "sidebar.plugin_shortcuts": "插件快捷入口", + "sidebar.plugin_shortcuts_hint": "选择显示在左侧的插件", + "sidebar.plugin_shortcuts_empty": "安装带工作台的插件后,即可添加快捷入口。", + "sidebar.manage_plugins": "管理插件", "plugin_workshop.create_session_failed": "无法为插件工坊新建对话。", "plugin_workshop.operation_failed": "插件工坊操作失败", "plugin_workshop.blank_description": "在左侧告诉 AI 你想做什么插件,Studio、MCP Server、Skills 和脚本会统一生成到当前项目的插件目录中。", @@ -1201,6 +1206,7 @@ export default { "project_overview.no_agent_presets": "没有匹配的预设,或已全部添加", "project_overview.agent_limit": "一个对话最多配置 24 个智能体", "project_overview.agents": "智能体", + "project_overview.new_conversations_inherit": "这里保存项目默认智能体。之后新建的对话会继承,已有对话的配置保持不变。", "project_overview.agent_name": "名称", "project_overview.agent_role": "职责", "project_overview.agent_no_role": "还没有设置职责", diff --git a/apps/app/src/lib/artifacts.ts b/apps/app/src/lib/artifacts.ts index 86e971a2d..0509f23a0 100644 --- a/apps/app/src/lib/artifacts.ts +++ b/apps/app/src/lib/artifacts.ts @@ -14,6 +14,7 @@ export type ArtifactItem = { messageId: string messageIndex: number updatedAt?: number + verifiedFromWrite?: boolean target: OpenTarget } @@ -244,6 +245,8 @@ const INTERNAL_OUTPUT_NAME_PATTERN = /^(?:SKILL|AGENTS|CLAUDE|brief|template|man /** Only user-facing files belong in the conversation output list. */ export function isConversationOutputArtifact(artifact: ArtifactItem) { if (artifact.type === "unknown") return false; + // Authored skills are deliverables too; merely loading a runtime skill isn't. + if (artifact.verifiedFromWrite && artifact.name.toLowerCase() === "skill.md") return true; if (INTERNAL_OUTPUT_PATH_PATTERN.test(artifact.path)) return false; if (INTERNAL_OUTPUT_NAME_PATTERN.test(artifact.name)) return false; return true; @@ -526,6 +529,7 @@ function addArtifact( sequence: number, verifiedTargets: OpenTarget[], verifiedTarget?: OpenTarget, + verifiedFromWrite = false, ) { const requestedPath = normalizeArtifactPath(path); const requestedType = getArtifactType(requestedPath); @@ -546,6 +550,7 @@ function addArtifact( messageIndex, sequence, updatedAt: target.updatedAt, + ...(verifiedFromWrite || artifacts.get(key)?.verifiedFromWrite ? { verifiedFromWrite: true } : {}), target, }); } @@ -563,7 +568,7 @@ export function getArtifactsFromMessages(messages: UIMessage[], openTargets: Ope const verifiedTarget = candidate.verifiedFromWrite ? markTargetExists(openTargetFromArtifactPath(candidate.path, getArtifactName(candidate.path), getArtifactType(candidate.path), openTargets)) : undefined; - addArtifact(artifacts, candidate.path, message.id, messageIndex, sequence, openTargets, verifiedTarget); + addArtifact(artifacts, candidate.path, message.id, messageIndex, sequence, openTargets, verifiedTarget, candidate.verifiedFromWrite); sequence += 1; } }); diff --git a/apps/app/src/react-app/design-system/extension-icon-src.ts b/apps/app/src/react-app/design-system/extension-icon-src.ts index e31d0ef6c..3ed932ee8 100644 --- a/apps/app/src/react-app/design-system/extension-icon-src.ts +++ b/apps/app/src/react-app/design-system/extension-icon-src.ts @@ -10,6 +10,7 @@ const featuredPluginIconSrc: Readonly> = { linear: "/ext-linear.svg", notion: "/ext-notion.svg", "operation-recorder": "/ext-operation-recorder.png", + "plugin-workshop": "/ext-plugin-workshop.svg", sentry: "/ext-sentry.svg", "short-video-studio": "/ext-short-video.png", stripe: "/ext-stripe.svg", diff --git a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx index cf03292c0..59eff4d11 100644 --- a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx +++ b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx @@ -22,6 +22,8 @@ import { type DenUser, } from "../../../app/lib/den"; import { exchangeHandoffAndSignIn } from "../../../app/lib/den-handoff"; +import { setEnterpriseConnectionAccount } from "../../../app/lib/enterprise-connections"; +import { clearEnterpriseLoginState } from "../../../app/lib/work-context"; import { denSessionUpdatedEvent, denSettingsChangedEvent, @@ -107,6 +109,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { const token = settings.authToken?.trim() ?? ""; if (!token) { + clearEnterpriseLoginState(); setUser(null); setError(null); lastSignalRetryAtRef.current = null; @@ -129,6 +132,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { if (currentRun !== refreshTokenRef.current) return; + setEnterpriseConnectionAccount({ userId: nextUser.id, baseUrl: settings.baseUrl, token }); await ensureDenActiveOrganization({ forceServerSync: !settings.activeOrgId?.trim() || !settings.activeOrgSlug?.trim(), @@ -145,6 +149,7 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { const failureStatus = resolveDenAuthFailureStatus(nextError); if (failureStatus === "signed_out") { + clearEnterpriseLoginState(); clearDenSession(); setUser(null); lastSignalRetryAtRef.current = null; @@ -164,7 +169,13 @@ export function DenAuthProvider({ children }: DenAuthProviderProps) { if (typeof window === "undefined") return; - const handleSessionUpdated = () => { + const handleSessionUpdated = (event: WindowEventMap[typeof denSessionUpdatedEvent]) => { + const detail = event.detail; + if (detail?.status === "success" && detail.user && detail.baseUrl && detail.token) { + setEnterpriseConnectionAccount({ userId: detail.user.id, baseUrl: detail.baseUrl, token: detail.token }); + } else if (detail?.status === "signed_out") { + clearEnterpriseLoginState(); + } void refresh(); }; diff --git a/apps/app/src/react-app/domains/enterprise/enterprise-messages.tsx b/apps/app/src/react-app/domains/enterprise/enterprise-messages.tsx new file mode 100644 index 000000000..6b9ea8395 --- /dev/null +++ b/apps/app/src/react-app/domains/enterprise/enterprise-messages.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from "react"; +import { matchPath, useLocation, useNavigate } from "react-router-dom"; +import { Loader2, Mail, RefreshCw } from "lucide-react"; +import type { EnterpriseMessage } from "@ipollowork/types/enterprise"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import type { EnterpriseConnection } from "@/app/lib/enterprise-connections"; +import { enterpriseApi, enterpriseErrorMessage, enterpriseIdentity, requestEnterpriseMessageImport } from "@/app/lib/enterprise-client"; + +export function useEnterpriseMessages(connection: EnterpriseConnection | null) { + const identity = connection ? enterpriseIdentity(connection) : ""; + const [state, setState] = useState<{ identity: string; items: EnterpriseMessage[]; cursor: string | null }>({ identity: "", items: [], cursor: null }); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [revision, setRevision] = useState(0); + const request = useRef(null); + const options = useRef(connection); options.current = connection; + useEffect(() => { + if (!connection?.features?.messaging) { setState({ identity, items: [], cursor: null }); setError(null); setLoading(false); return; } + let current = true; + let inFlight = false; + let retryDelay = 30_000; + let timer: number | undefined; + const poll = async () => { + if (!current || inFlight || !options.current) return; + inFlight = true; setLoading(true); + const controller = new AbortController(); request.current = controller; + try { + const page = await enterpriseApi.messages(options.current, null, controller.signal); + if (!current) return; + setState((previous) => { + const updated = new Map(page.items.map((message) => [message.id, message])); + const old = previous.identity === identity ? previous.items : []; + return { identity, items: [...page.items, ...old.filter((message) => !updated.has(message.id))].slice(0, 500), cursor: previous.identity === identity && old.length > 50 ? previous.cursor : page.nextCursor }; + }); + setError(null); retryDelay = 30_000; + } catch (cause) { if (current && !controller.signal.aborted) { setError(enterpriseErrorMessage(cause)); retryDelay = Math.min(retryDelay * 2, 300_000); } } + finally { inFlight = false; if (current) { setLoading(false); timer = window.setTimeout(() => void poll(), retryDelay); } } + }; + const focus = () => { if (timer) window.clearTimeout(timer); void poll(); }; + window.addEventListener("focus", focus); void poll(); + return () => { current = false; request.current?.abort(); if (timer) window.clearTimeout(timer); window.removeEventListener("focus", focus); }; + }, [identity, connection?.features?.messaging, revision]); + const more = async () => { + if (!options.current || state.identity !== identity || !state.cursor || loading) return; + const snapshotIdentity = identity; + setLoading(true); setError(null); + try { + const page = await enterpriseApi.messages(options.current, state.cursor); + if (!options.current || enterpriseIdentity(options.current) !== snapshotIdentity) return; + setState((previous) => ({ identity, items: [...new Map([...previous.items, ...page.items].map((message) => [message.id, message])).values()].slice(0, 500), cursor: page.nextCursor === state.cursor ? null : page.nextCursor })); + } catch (cause) { if (options.current && enterpriseIdentity(options.current) === snapshotIdentity) setError(enterpriseErrorMessage(cause)); } + finally { if (options.current && enterpriseIdentity(options.current) === snapshotIdentity) setLoading(false); } + }; + const items = state.identity === identity ? state.items : []; + return { items, cursor: state.identity === identity ? state.cursor : null, error, loading, refresh: () => setRevision((value) => value + 1), more, unread: items.filter((item) => !item.readAt && !item.ignoredAt && !item.revokedAt).length }; +} + +export function EnterpriseMessageList({ connection, inbox, onExit }: { + connection: EnterpriseConnection; inbox: ReturnType; onExit: () => void; +}) { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [selected, setSelected] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const active = useRef(true); + useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); + const open = async (message: EnterpriseMessage) => { + setBusy(true); setError(null); + try { + const current = await enterpriseApi.message(connection, message.id); + if (!active.current) return; + setSelected(current); + if (!current.readAt) await enterpriseApi.messageAction(connection, current.id, "read"); + if (active.current) inbox.refresh(); + } catch (cause) { if (active.current) setError(enterpriseErrorMessage(cause)); } + finally { if (active.current) setBusy(false); } + }; + const ignore = async () => { + if (!selected) return; + setBusy(true); setError(null); + try { await enterpriseApi.messageAction(connection, selected.id, "ignore"); if (active.current) { setSelected(null); inbox.refresh(); } } + catch (cause) { if (active.current) setError(enterpriseErrorMessage(cause)); } + finally { if (active.current) setBusy(false); } + }; + return
+

{connection.shortName} · 企业消息

+ {!connection.features?.messaging ?

当前企业服务尚未开放企业消息。

: <> + {inbox.error || error ?

{error || inbox.error}

: null} + {inbox.items.map((message) => )} + {inbox.loading && !inbox.items.length ?

正在加载企业消息

: !inbox.items.length && !inbox.error ?

暂无企业消息

: null} + {inbox.cursor && inbox.items.length < 500 ? : null} + } + { if (!value && !busy) setSelected(null); }}>{selected?.title}{selected?.senderName} · {connection.name} + {selected?.markdown ?
{selected.markdown}
:

{selected?.kind === "session_share" ? selected.revokedAt ? "这条分享已撤回,企业端已清理总结正文。" : selected.acceptedAt ? "总结已保存到接收设备,企业端不再向你提供正文。可在原设备打开已保存的会话。" : selected.ignoredAt ? "这条分享已忽略,不再领取总结。" : "这条分享暂不可领取。" : selected?.kind === "resource_granted" ? "你可以在当前企业的资源入口中查看已授权资源。" : "企业通知"}

} + {error ?

{error}

: null} + {selected?.kind === "session_share" ? : selected?.resourceType ? : } +
+
; +} diff --git a/apps/app/src/react-app/domains/enterprise/enterprise-people-picker.tsx b/apps/app/src/react-app/domains/enterprise/enterprise-people-picker.tsx new file mode 100644 index 000000000..9cd779f31 --- /dev/null +++ b/apps/app/src/react-app/domains/enterprise/enterprise-people-picker.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Building2, ChevronDown, ChevronRight, Loader2, Search, UserRound, X } from "lucide-react"; +import type { EnterpriseDepartment, EnterpriseMember } from "@ipollowork/types/enterprise"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { enterpriseApi, enterpriseErrorMessage, enterpriseIdentity } from "@/app/lib/enterprise-client"; +import type { EnterpriseConnection } from "@/app/lib/enterprise-connections"; + +export type EnterprisePeopleSelection = { memberIds: string[]; departmentIds: string[] }; +type MemberPage = { items: EnterpriseMember[]; nextCursor: string | null }; +const EMPTY: EnterprisePeopleSelection = { memberIds: [], departmentIds: [] }; + +export function EnterprisePeoplePicker({ connection, value, onChange, allowDepartments = false, disabled = false }: { + connection: EnterpriseConnection; value: EnterprisePeopleSelection; + onChange: (value: EnterprisePeopleSelection) => void; allowDepartments?: boolean; disabled?: boolean; +}) { + const identity = enterpriseIdentity(connection); + const [departments, setDepartments] = useState([]); + const [departmentCursor, setDepartmentCursor] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [pages, setPages] = useState>({}); + const [search, setSearch] = useState(""); + const searchRef = useRef(search); searchRef.current = search; + const [searchPage, setSearchPage] = useState(null); + const [loading, setLoading] = useState("departments"); + const [error, setError] = useState(null); + const generation = useRef(0); + const controller = useRef(null); + const connectionRef = useRef(connection); + connectionRef.current = connection; + const selectionRef = useRef(value); + selectionRef.current = value; + const childrenById = useMemo(() => { + const groups = new Map(); + for (const department of departments) groups.set(department.parentId, [...(groups.get(department.parentId) ?? []), department]); + return groups; + }, [departments]); + const descendantIds = (id: string) => { + const result = new Set(); + const pending = [{ id, depth: 0 }]; + while (pending.length) { + const item = pending.pop(); + if (!item || result.has(item.id) || item.depth > 24) continue; + result.add(item.id); + pending.push(...(childrenById.get(item.id) ?? []).map((child) => ({ id: child.id, depth: item.depth + 1 }))); + } + return result; + }; + + useEffect(() => { + const request = new AbortController(); + controller.current = request; + generation.current += 1; + setDepartments([]); setPages({}); setExpanded(new Set()); setSearch(""); setSearchPage(null); setError(null); setLoading("departments"); + void enterpriseApi.departments(connectionRef.current, null, request.signal).then((page) => { + setDepartments(page.items); setDepartmentCursor(page.nextCursor); + }).catch((cause: unknown) => { if (!request.signal.aborted) setError(enterpriseErrorMessage(cause)); }) + .finally(() => { if (!request.signal.aborted) setLoading(null); }); + return () => { request.abort(); generation.current += 1; }; + }, [identity]); + + useEffect(() => { + if (!search.trim()) { setSearchPage(null); return; } + const request = new AbortController(); + const timer = window.setTimeout(() => { + setLoading("search"); setError(null); + void enterpriseApi.members(connectionRef.current, { search }, request.signal).then(setSearchPage) + .catch((cause: unknown) => { if (!request.signal.aborted) setError(enterpriseErrorMessage(cause)); }) + .finally(() => { if (!request.signal.aborted) setLoading(null); }); + }, 250); + return () => { window.clearTimeout(timer); request.abort(); }; + }, [identity, search]); + + const run = async (key: string, action: () => Promise) => { + const epoch = generation.current; + setLoading(key); setError(null); + try { await action(); } catch (cause) { if (epoch === generation.current) setError(enterpriseErrorMessage(cause)); } + finally { if (epoch === generation.current) setLoading(null); } + }; + const loadMembers = (departmentId: string, more = false) => run(departmentId, async () => { + const epoch = generation.current; + const page = await enterpriseApi.members(connectionRef.current, { departmentId, cursor: more ? pages[departmentId]?.nextCursor : null }, controller.current?.signal); + if (epoch !== generation.current) return; + setPages((current) => ({ ...current, [departmentId]: { ...page, items: more ? [...(current[departmentId]?.items ?? []), ...page.items] : page.items } })); + }); + const toggleMember = (id: string) => { + const selected = selectionRef.current; + if (!selected.memberIds.includes(id) && selected.memberIds.length >= 500) { setError("单次最多选择 500 人,请缩小选择范围。"); return; } + onChange({ ...selected, memberIds: selected.memberIds.includes(id) ? selected.memberIds.filter((item) => item !== id) : [...selected.memberIds, id] }); + }; + const selectDepartment = (departmentId: string) => { + if (allowDepartments) { + if (!value.departmentIds.includes(departmentId) && value.departmentIds.length >= 100) { setError("单次最多授权 100 个部门,请缩小选择范围。"); return; } + onChange({ ...value, departmentIds: value.departmentIds.includes(departmentId) ? value.departmentIds.filter((id) => id !== departmentId) : [...value.departmentIds, departmentId] }); + return; + } + void run(departmentId, async () => { + const epoch = generation.current; + const members: EnterpriseMember[] = []; + const cursors = new Set(); + let cursor: string | null = null; + do { + const page = await enterpriseApi.members(connectionRef.current, { departmentId, includeDescendants: true, cursor }, controller.current?.signal); + members.push(...page.items.filter((member) => member.status === "active")); + cursor = page.nextCursor; + if (members.length > 500 || cursors.size >= 20 || (cursor && cursors.has(cursor))) throw new Error("enterprise_selection_limit"); + if (cursor) cursors.add(cursor); + } while (cursor); + if (epoch !== generation.current) return; + setPages((current) => { + const next = { ...current }; + for (const id of descendantIds(departmentId)) next[id] = { items: members.filter((member) => member.departmentId === id), nextCursor: null }; + return next; + }); + const selected = selectionRef.current; + const ids = members.map((member) => member.id); + const allSelected = ids.length > 0 && ids.every((id) => selected.memberIds.includes(id)); + if (!allSelected && new Set([...selected.memberIds, ...ids]).size > 500) throw new Error("enterprise_selection_limit"); + onChange({ ...selected, memberIds: allSelected ? selected.memberIds.filter((id) => !ids.includes(id)) : [...new Set([...selected.memberIds, ...ids])] }); + }); + }; + const allMembers = [...Object.values(pages).flatMap((page) => page.items), ...(searchPage?.items ?? [])]; + const labelForMember = (id: string, index: number) => { + const member = allMembers.find((item) => item.id === id); + return member?.displayName || member?.email || `已选员工 ${index + 1}`; + }; + const memberRow = (member: EnterpriseMember) => ; + const departmentNode = (department: EnterpriseDepartment, visited: Set, depth = 0): ReactNode => { + if (visited.has(department.id) || depth > 24) return null; + const seen = new Set(visited).add(department.id); + const page = pages[department.id]; + const children = childrenById.get(department.id) ?? []; + const hasChildren = children.length > 0 || Boolean(page?.items.length || page?.nextCursor); + const canExpand = hasChildren || !page; + const subtree = descendantIds(department.id); + const visibleIds = [...new Set(allMembers.filter((member) => member.departmentId && subtree.has(member.departmentId)).map((member) => member.id))]; + const selectedCount = visibleIds.filter((id) => value.memberIds.includes(id)).length; + const isExpanded = expanded.has(department.id) && hasChildren; + const checked = allowDepartments ? value.departmentIds.includes(department.id) : Boolean(visibleIds.length && [...subtree].every((id) => pages[id] && !pages[id].nextCursor) && selectedCount === visibleIds.length); + return
+
+ {canExpand ? :
+ {isExpanded ?
+ {children.map((item) => departmentNode(item, seen, depth + 1))} + {page?.items.map(memberRow)} + {page?.nextCursor ? : null} +
: null} +
; + }; + return
+ + {error ?

{error}

: null} +
+
+ {loading ?

正在加载通讯录

: null} + {search.trim() ? searchPage?.items.map(memberRow) : <> + {departments.filter((item) => !item.parentId || !departments.some((parent) => parent.id === item.parentId)).map((item) => departmentNode(item, new Set()))} + + {expanded.has("unassigned") ? pages.unassigned?.items.map(memberRow) : null} + {expanded.has("unassigned") && pages.unassigned?.nextCursor ? : null} + {departmentCursor ? : null} + } + {search.trim() && searchPage?.nextCursor ? : null} + {searchPage && !searchPage.items.length && !loading ?

没有匹配的人员

: null} +
+

已选 {value.memberIds.length} 人{allowDepartments ? ` · ${value.departmentIds.length} 个部门` : ""}

+ {value.memberIds.map((id, index) =>
{labelForMember(id, index)}
)} + {value.departmentIds.map((id) =>
{departments.find((item) => item.id === id)?.name ?? "已选部门"}
)} +
+
+
; +} diff --git a/apps/app/src/react-app/domains/enterprise/enterprise-resource-controls.tsx b/apps/app/src/react-app/domains/enterprise/enterprise-resource-controls.tsx new file mode 100644 index 000000000..1beca74bb --- /dev/null +++ b/apps/app/src/react-app/domains/enterprise/enterprise-resource-controls.tsx @@ -0,0 +1,129 @@ +import type { EnterpriseResourceType } from "@ipollowork/types/enterprise"; +import { useEffect, useRef, useState } from "react"; +import { Loader2, RefreshCw, ShieldCheck, Upload } from "lucide-react"; +import type { EnterpriseAccess, EnterpriseManagedResource, EnterpriseSubmission } from "@ipollowork/types/enterprise"; +import { MAX_TEMPLATE_PACKAGE_BYTES } from "@ipollowork/types/templates"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { canManageEnterprise, enterpriseApi, enterpriseErrorMessage, enterpriseIdentity } from "@/app/lib/enterprise-client"; +import type { EnterpriseConnection } from "@/app/lib/enterprise-connections"; +import { EnterprisePeoplePicker } from "./enterprise-people-picker"; + +const REVIEW_LABELS = { pending: "待审核", approved: "已通过", rejected: "已退回" }; +const STATUS_LABELS = { draft: "草稿", published: "已发布", archived: "已下架" }; + +function ResourceAccessDialog({ connection, resource, onClose, onSaved }: { + connection: EnterpriseConnection; resource: EnterpriseManagedResource; onClose: () => void; onSaved: () => void; +}) { + const [access, setAccess] = useState(null); + const [includeDescendants, setIncludeDescendants] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let current = true; + void enterpriseApi.access(connection, resource.id).then((value) => { if (current) { setAccess(value); setIncludeDescendants(value.departments.length ? value.departments.every((item) => item.includeDescendants) : true); } }) + .catch((cause: unknown) => { if (current) setError(enterpriseErrorMessage(cause)); }); + return () => { current = false; }; + }, [connection.id, connection.origin, connection.membership.id, connection.session.token, resource.id]); + const save = async () => { + if (!access) return; + setBusy(true); setError(null); + try { await enterpriseApi.setAccess(connection, resource.id, access); onSaved(); onClose(); } + catch (cause) { setError(enterpriseErrorMessage(cause)); } + finally { setBusy(false); } + }; + return { if (!open && !busy) onClose(); }}>分配资源权限{resource.name} · {connection.name} + {error ?

{error}

: null} + {access ? <> +
{(["restricted", "all", "admins"] as const).map((scope) => )}
+ {access.access === "restricted" ? <> item.id) }} onChange={(selected) => setAccess({ ...access, memberIds: selected.memberIds, departments: selected.departmentIds.map((id) => access.departments.find((item) => item.id === id) ?? { id, includeDescendants }) })} /> + + : null} +

保存后通知员工。清空指定对象可收回该范围权限,已下载的本地副本另行管理。

+ : !error ?

正在读取权限

: null} + +
; +} + +export function EnterpriseResourceControls({ connection, type, onChanged }: { + connection: EnterpriseConnection; type: EnterpriseResourceType; onChanged?: () => void; +}) { + const [open, setOpen] = useState(false); + const [view, setView] = useState<"manage" | "mine">("mine"); + const [resources, setResources] = useState([]); + const [submissions, setSubmissions] = useState([]); + const [cursor, setCursor] = useState(null); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [accessResource, setAccessResource] = useState(null); + const [reviewResource, setReviewResource] = useState(null); + const [reviewNote, setReviewNote] = useState(""); + const [file, setFile] = useState(null); + const input = useRef(null); + const epoch = useRef(0); + const manager = canManageEnterprise(connection, type); + const publishing = type === "template" ? connection.features?.templatePublishing : type === "skill" ? connection.features?.skillPublishing : connection.features?.extensionPublishing; + const label = type === "template" ? "模板" : type === "skill" ? "技能" : "插件"; + const identity = enterpriseIdentity(connection); + useEffect(() => { setOpen(false); setResources([]); setSubmissions([]); setAccessResource(null); setReviewResource(null); setFile(null); setError(null); setNotice(null); setBusy(false); epoch.current += 1; }, [identity]); + useEffect(() => { setError(null); setNotice(null); if (!manager) { setOpen(false); setView("mine"); setResources([]); setAccessResource(null); setReviewResource(null); setBusy(false); epoch.current += 1; } }, [manager]); + const refresh = async (more = false) => { + const request = ++epoch.current; + setBusy(true); setError(null); + try { + if (view === "manage") { + if (!manager || !connection.features?.resourceManagement) throw new Error("enterprise_feature_unavailable"); + const page = await enterpriseApi.resources(connection, type, more ? cursor : null, search); + if (request === epoch.current) { setResources((current) => more ? [...new Map([...current, ...page.items].map((item) => [item.id, item])).values()].slice(0, 500) : page.items); setCursor(page.nextCursor === cursor && more ? null : page.nextCursor); } + } else { + if (!publishing) throw new Error("enterprise_feature_unavailable"); + const page = await enterpriseApi.submissions(connection, type); + if (request === epoch.current) setSubmissions(page.items); + } + } catch (cause) { if (request === epoch.current) setError(enterpriseErrorMessage(cause)); } + finally { if (request === epoch.current) setBusy(false); } + }; + useEffect(() => { const timer = open ? window.setTimeout(() => void refresh(), search ? 250 : 0) : undefined; return () => { window.clearTimeout(timer); epoch.current += 1; }; }, [open, view, identity, type, search]); + const mutation = async (action: () => Promise, success: string) => { + const request = epoch.current; + setBusy(true); setError(null); setNotice(null); + try { await action(); if (request !== epoch.current) return; setNotice(success); setFile(null); onChanged?.(); await refresh(); } + catch (cause) { if (request === epoch.current) setError(enterpriseErrorMessage(cause)); } + finally { if (request === epoch.current) setBusy(false); } + }; + const upload = () => { + if (!file || !publishing) return; + const valid = type === "template" ? /\.(ipwp|ipwt)$/i.test(file.name) : type === "skill" ? /\.(md|ipollowork-skill)$/i.test(file.name) : /\.ipollowork-plugin$/i.test(file.name); + const maximum = type === "skill" && /\.md$/i.test(file.name) ? 1024 * 1024 : MAX_TEMPLATE_PACKAGE_BYTES; + if (!valid || !file.size || file.size > maximum) { setError(`请选择有效的${label}${type === "skill" ? "文件" : "包"},文件大小不能超过 ${Math.round(maximum / 1024 / 1024)} MB。`); return; } + void mutation(() => enterpriseApi.submit(connection, type, file), "投稿已提交,可在我的投稿中查看审核状态。"); + }; + // Employee submission controls are temporarily hidden; keep the workflow for re-enabling. + if (!manager && type !== "skill") return null; + return <> + + { if (!busy) setOpen(value); }}>{manager ? `企业${label}管理` : `我的${label}投稿`}{connection.name} · {manager ? "查看资源和投稿,分配使用权限" : "提交自己制作的资源,查看审核结果"} +
{manager ? : null}
+
setFile(event.target.files?.[0] ?? null)} />{file?.name ?? (type === "skill" ? "支持技能 MD 或 .ipollowork-skill 包,审核后在企业内分发" : `上传自己制作的${label},审核后在企业内分发`)}
+ {!publishing ?

当前企业服务尚未开放员工{label}投稿,请联系管理员升级。

: null} + {error ?

{error}

: null} + {notice ?

{notice}

: null} + {busy ?

正在处理

: null} + {view === "manage" && manager ? <>
+ {resources.map((item) =>

{item.name}

{STATUS_LABELS[item.status]} · {item.latestVersion ? `v${item.latestVersion.version} · ${REVIEW_LABELS[item.latestVersion.reviewStatus]}` : "暂无版本"}{item.ownerName ? ` · ${item.ownerName}` : ""}

+ + {item.latestVersion?.reviewStatus === "pending" ? : null} + {item.status === "published" ? : null} +
)} + {!resources.length && !busy && !error ?

企业中暂无{label}

: null} +
{cursor && resources.length < 500 ? : null}{resources.length >= 500 ?

已显示前 500 个资源,请在企业管理服务中查看完整列表。

: null} :
{submissions.map((item) =>

{item.resourceName || label} · v{item.version}

{REVIEW_LABELS[item.reviewStatus]}{item.reviewNote ? ` · ${item.reviewNote}` : ""}

)}{!submissions.length && !busy && !error ?

还没有投稿,选择自己的{label}{type === "skill" ? "文件" : "包"}提交审核。

: null}
} +
+ {manager && accessResource ? setAccessResource(null)} onSaved={() => { setNotice("权限已保存并下发。"); void refresh(); }} /> : null} + { if (!value && !busy) setReviewResource(null); }}>审核{label}投稿{reviewResource?.name}
-
+
-
+
-

保存只进入草稿箱,不会自动发布。

+

保存只进入草稿箱,不会自动发布。

+