mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-10-21 05:04:36 +08:00
feat: 收录应用开发发布技能与双语示例 (#214)
## 中文 新增按需调用的内置技能 `app-developer`(应用开发与发布):中英正文、sidecar、MIT 许可、静态与服务两个通用工作台示例,以及作者接口与分发参考,并登记进 `builtin-skills.json`。作者管理优先使用 AppManage 的有限动作,执行前须通过 `authoring_capabilities` 确认 `app-authoring-v1`,能力不可用时停止受影响的操作;外部 Node/Docker 运行时、托管、Git 服务与数据库的条款和可能费用已在发现文案、compatibility 与正文披露。另将 dev-environment-setup 探测参考中的三处用户 home 示例改为通用路径。 与 #220、#212 合入后的 main 解决 manifest 冲突:技能总数 144,市场版本 1.5.13。验证:catalog 与 i18n 校验 0 errors / 106 warnings(含新技能尚未 ref-bound review 的治理提醒),translate --check 与 git diff --check 通过;远端 validate、translate、CodeQL 通过。Copilot 因额度用尽未能审查。 ## English Add the on-demand builtin `app-developer` skill (application development and publishing): English/Chinese bodies, sidecar, MIT license, static and service workbench examples, plus authoring-interface and distribution references, registered in `builtin-skills.json`. Authoring prefers AppManage's finite actions and must confirm `app-authoring-v1` through `authoring_capabilities` first, stopping affected operations when the capability is unavailable. External Node/Docker runtime, hosting, Git service and database terms and possible costs are disclosed. Three user-home examples in the dev-environment-setup probe reference are neutralized. The manifest conflict with main after #220 and #212 is resolved: 144 skills, market version 1.5.13. Catalog and i18n validation report 0 errors with 106 warnings (including the new skill's pending ref-bound review notice); translate --check and git diff --check pass; remote validate, translate and CodeQL passed. Copilot could not review because of its quota limit.
This commit is contained in:
9
skills/app-developer/examples/service-workbench/LICENSE
Normal file
9
skills/app-developer/examples/service-workbench/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DesireCore
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
147
skills/app-developer/examples/service-workbench/app-release.json
Normal file
147
skills/app-developer/examples/service-workbench/app-release.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"canonicalization": "app-release-c14n-v1",
|
||||
"definition": {
|
||||
"id": "service-notes-workbench",
|
||||
"name": "服务笔记工作台",
|
||||
"version": "1.0.0",
|
||||
"productKind": "application",
|
||||
"icon": "PanelsTopLeft",
|
||||
"iconLetter": "台",
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "笔记",
|
||||
"kind": "embedded-web",
|
||||
"serviceRef": "workbench:main"
|
||||
}
|
||||
],
|
||||
"placementPolicy": {
|
||||
"showInMyApps": true,
|
||||
"allowNavPin": true,
|
||||
"allowAgentBinding": true
|
||||
}
|
||||
},
|
||||
"delivery": {
|
||||
"type": "native-app",
|
||||
"runtimeInstructionsPath": "runtime/运行说明.md"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"role": "license",
|
||||
"sha256": "e863481f423546e8984c07ffa00609d338b7f7533f60e89392301028163aa89e",
|
||||
"size": 1067
|
||||
},
|
||||
{
|
||||
"path": "application.json",
|
||||
"role": "definition",
|
||||
"sha256": "34f3ef80d33f3182f461f93e646d3fdb13865fb07214b505b55a64514c1284e1",
|
||||
"size": 433
|
||||
},
|
||||
{
|
||||
"path": "data-contract.json",
|
||||
"role": "data-contract",
|
||||
"sha256": "e195b2fd60a2798141c4c75ab57e0927d115bfba7888b0881b69dffa9dcb26cc",
|
||||
"size": 266
|
||||
},
|
||||
{
|
||||
"path": "runtime/server.mjs",
|
||||
"role": "runtime",
|
||||
"sha256": "3ccea1e06f3371e98cc8277d2b82d70cdcca7dd43ff8c40ab43a3c14ba0ee456",
|
||||
"size": 2633
|
||||
},
|
||||
{
|
||||
"path": "runtime/运行说明.md",
|
||||
"role": "documentation",
|
||||
"sha256": "743e8eaa0ae53e605563eca8620ba09eddf4adaf4ad9dd3c5593d080bedfb8c1",
|
||||
"size": 3081
|
||||
},
|
||||
{
|
||||
"path": "ui/app.js",
|
||||
"role": "ui",
|
||||
"sha256": "5ece9785b32efa8d30531354a8775b93ee1c4b6a470ffc37e86625d9c01b2b35",
|
||||
"size": 9111
|
||||
},
|
||||
{
|
||||
"path": "ui/bridge.js",
|
||||
"role": "ui",
|
||||
"sha256": "b227966a71d10002fadc2fba8d278ef85b7ab6aa4bb86afca4599734809bcb63",
|
||||
"size": 2984
|
||||
},
|
||||
{
|
||||
"path": "ui/index.html",
|
||||
"role": "ui",
|
||||
"sha256": "d2796efba92f7a8bf4da5d467311ba191d7f6af1eee1872b0f0707031f876973",
|
||||
"size": 2624
|
||||
},
|
||||
{
|
||||
"path": "说明.md",
|
||||
"role": "documentation",
|
||||
"sha256": "2e2393b602ba22fd16d070d1610fe9b9a48f1c2a0134f8dfe13df1220dac5419",
|
||||
"size": 2506
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "worktree",
|
||||
"sha256": "f2b9a34e87370518a1f9c267c2fce453f1c599ea3c006e8b53b762bb5a354547"
|
||||
},
|
||||
"supportedPlatforms": [
|
||||
"darwin",
|
||||
"win32",
|
||||
"linux"
|
||||
],
|
||||
"minimumClientVersion": "10.0.180",
|
||||
"requiredCapabilities": [
|
||||
"app-authoring-v1",
|
||||
"release",
|
||||
"runtime-binding",
|
||||
"bridge",
|
||||
"data",
|
||||
"view-state"
|
||||
],
|
||||
"dataContract": {
|
||||
"schemaVersion": 1,
|
||||
"formatVersion": "notes-v1",
|
||||
"readableVersions": [
|
||||
"notes-v1"
|
||||
],
|
||||
"writableVersions": [
|
||||
"notes-v1"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"key": "notes",
|
||||
"path": "notes.json",
|
||||
"maxBytes": 131072,
|
||||
"contentType": "application/json"
|
||||
}
|
||||
],
|
||||
"uninstallRetention": "retain"
|
||||
},
|
||||
"bridgeCapabilities": [
|
||||
"connect",
|
||||
"capabilities",
|
||||
"readData",
|
||||
"saveData",
|
||||
"lookupDataMutation",
|
||||
"publishSelection",
|
||||
"requestWork",
|
||||
"lookupRequest",
|
||||
"readWork",
|
||||
"controlWork",
|
||||
"openResource",
|
||||
"readResource",
|
||||
"saveViewState"
|
||||
],
|
||||
"viewStateContract": {
|
||||
"schemaVersion": 1,
|
||||
"keys": [
|
||||
"selection"
|
||||
],
|
||||
"maxBytes": 1024,
|
||||
"formatVersion": "1"
|
||||
},
|
||||
"instructionsPath": "说明.md",
|
||||
"releaseDigest": "c3ef146b7393d530db5ed44eb8de637551abe18b13e98f9c441cbbf36dd92c68"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "service-notes-workbench",
|
||||
"name": "服务笔记工作台",
|
||||
"version": "1.0.0",
|
||||
"productKind": "application",
|
||||
"icon": "PanelsTopLeft",
|
||||
"iconLetter": "台",
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "笔记",
|
||||
"kind": "embedded-web",
|
||||
"serviceRef": "workbench:main"
|
||||
}
|
||||
],
|
||||
"placementPolicy": {
|
||||
"showInMyApps": true,
|
||||
"allowNavPin": true,
|
||||
"allowAgentBinding": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schemaVersion": 1, "formatVersion": "notes-v1", "readableVersions": ["notes-v1"], "writableVersions": ["notes-v1"],
|
||||
"files": [{ "key": "notes", "path": "notes.json", "maxBytes": 131072, "contentType": "application/json" }],
|
||||
"uninstallRetention": "retain"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"description": "显式收录网页与只读分析服务;笔记使用平台有限data key,不写固定程序目录。",
|
||||
"delivery": {
|
||||
"type": "native-app",
|
||||
"runtimeInstructionsPath": "runtime/运行说明.md"
|
||||
},
|
||||
"supportedPlatforms": [
|
||||
"darwin",
|
||||
"win32",
|
||||
"linux"
|
||||
],
|
||||
"requiredCapabilities": [
|
||||
"app-authoring-v1",
|
||||
"release",
|
||||
"runtime-binding",
|
||||
"bridge",
|
||||
"data",
|
||||
"view-state"
|
||||
],
|
||||
"bridgeCapabilities": [
|
||||
"connect",
|
||||
"capabilities",
|
||||
"readData",
|
||||
"saveData",
|
||||
"lookupDataMutation",
|
||||
"publishSelection",
|
||||
"requestWork",
|
||||
"lookupRequest",
|
||||
"readWork",
|
||||
"controlWork",
|
||||
"openResource",
|
||||
"readResource",
|
||||
"saveViewState"
|
||||
],
|
||||
"dataContractPath": "data-contract.json",
|
||||
"files": [
|
||||
{
|
||||
"path": "application.json",
|
||||
"role": "definition"
|
||||
},
|
||||
{
|
||||
"path": "ui/index.html",
|
||||
"role": "ui"
|
||||
},
|
||||
{
|
||||
"path": "ui/bridge.js",
|
||||
"role": "ui"
|
||||
},
|
||||
{
|
||||
"path": "ui/app.js",
|
||||
"role": "ui"
|
||||
},
|
||||
{
|
||||
"path": "data-contract.json",
|
||||
"role": "data-contract"
|
||||
},
|
||||
{
|
||||
"path": "说明.md",
|
||||
"role": "documentation"
|
||||
},
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"role": "license"
|
||||
},
|
||||
{
|
||||
"path": "runtime/server.mjs",
|
||||
"role": "runtime"
|
||||
},
|
||||
{
|
||||
"path": "runtime/运行说明.md",
|
||||
"role": "documentation"
|
||||
}
|
||||
],
|
||||
"viewStateContract": {
|
||||
"schemaVersion": 1,
|
||||
"keys": [
|
||||
"selection"
|
||||
],
|
||||
"maxBytes": 1024,
|
||||
"formatVersion": "1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** 只读应用服务;业务数据在平台实例data key中,绝不写固定release目录。 */
|
||||
import http from 'node:http'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const port = Number(process.env.PORT)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('PORT_required')
|
||||
const files = new Map([
|
||||
['/', ['../ui/index.html','text/html; charset=utf-8']],
|
||||
['/app.js',['../ui/app.js','text/javascript; charset=utf-8']],
|
||||
['/bridge.js',['../ui/bridge.js','text/javascript; charset=utf-8']],
|
||||
])
|
||||
const server = http.createServer(async (request,response) => {
|
||||
const url = new URL(request.url ?? '/',`http://127.0.0.1:${port}`)
|
||||
response.setHeader('X-Content-Type-Options','nosniff')
|
||||
response.setHeader('Referrer-Policy','origin')
|
||||
if (request.method !== 'GET') { response.writeHead(405); return response.end() }
|
||||
if (url.pathname === '/.desirecore-app-identity') {
|
||||
response.setHeader('Content-Type','application/json')
|
||||
response.setHeader('Cache-Control','no-store')
|
||||
const identity = {proof:process.env.DESIRECORE_APP_RUNTIME_PROOF,releaseDigest:process.env.DESIRECORE_APP_RELEASE_DIGEST,
|
||||
scopeId:process.env.DESIRECORE_APP_SCOPE_ID,runtimeGeneration:process.env.DESIRECORE_APP_RUNTIME_GENERATION}
|
||||
if (Object.values(identity).some(value => typeof value !== 'string' || !value)) { response.writeHead(503); return response.end(JSON.stringify({error:'managed_runtime_identity_unavailable'})) }
|
||||
return response.end(JSON.stringify(identity))
|
||||
}
|
||||
if (url.pathname === '/api/summary') {
|
||||
const count = Number(url.searchParams.get('count'))
|
||||
if (!Number.isInteger(count) || count < 0 || count > 10000) { response.writeHead(400); return response.end() }
|
||||
response.setHeader('Content-Type','application/json')
|
||||
return response.end(JSON.stringify({count,summary:`当前工作台有 ${count} 条笔记。`,dataAccess:'none'}))
|
||||
}
|
||||
const entry = files.get(url.pathname)
|
||||
if (!entry) { response.writeHead(404); return response.end() }
|
||||
response.setHeader('Content-Type',entry[1])
|
||||
response.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'")
|
||||
try { response.end(readFileSync(fileURLToPath(new URL(entry[0],import.meta.url)))) }
|
||||
catch { response.writeHead(503); response.end('material_unavailable') }
|
||||
})
|
||||
server.listen(port,'127.0.0.1',() => console.log(`Application service listening on loopback port ${port}`))
|
||||
for (const signal of ['SIGINT','SIGTERM']) process.on(signal,() => server.close(() => process.exit(0)))
|
||||
@@ -0,0 +1,15 @@
|
||||
# 运行服务笔记工作台
|
||||
|
||||
需要用户实际选择并可用的 Node.js 运行时。此示例不安装 Node、Docker 或第三方数据库,不向平台注册 MCP。Node 服务只提供固定网页、只读计数分析和当前 TaskHost 运行身份握手;业务笔记使用平台有限 data key,与静态示例采用同一CAS保存约定。
|
||||
|
||||
在获授权的材料目录生成 release 并 prepare 后,使用原执行工具实际启动 `PORT="${PORT:-32000}" node runtime/server.mjs`,初次默认值须替换为实际空闲端口。保留该默认表达式,不能用写死的 PORT 赋值覆盖 Harness 后续注入的预览端口。端口需固定在原接受的执行合同,不能靠网页声明任意 endpoint。身份由原 TaskHost 在受控正式 spawn 时注入:DESIRECORE_APP_RUNTIME_PROOF、DESIRECORE_APP_RELEASE_DIGEST、DESIRECORE_APP_SCOPE_ID、DESIRECORE_APP_RUNTIME_GENERATION。应用不能自报固定 PID 或伪造 proof,不能将 proof 用于其他 API 或日志。
|
||||
|
||||
通过原 `AppManage.runtime_enroll` 的 `owner_request` 给真实 prepareRef、installationId、terminalId、endpoint.port 与 identityPath `/.desirecore-app-identity`,以及用户选择的 manual/on-open。普通 HttpRequest 不自动注入平台凭据,不能读取实例 token 或改 Origin 绕过认证。纳管读取原accepted-command冻结执行合同;由原执行工具明确停掉此前命令,commit后由原owner启动正式新代际,再核读运行身份和应用功能。原先裸启动的服务在没有managed身份时会明确返回503,它只能帮助开发调试,不能证明正式可信直开。
|
||||
|
||||
公开执行环境不接受任意.env。若改造应用需要后端直接读写数据,须使用实际数据owner授予并注入的独立根;缺授权时不写盘,不默认使用cwd或release目录。此教学服务没有后端数据写入,因此不需要APP_DATA_DIR,不持有平台凭据。
|
||||
|
||||
停止、更新、失败恢复沿原TaskHost/Run,不重复开第二个后台进程。scope/release/generation改变后旧连接失效;重启须重新取得owner身份与审批事实。端口被占用不接管,不把HTTP200作为运行归属证明。
|
||||
|
||||
开发时若需要直接看授权源码的修改,可用当前真实 draft、原 source 目录启动的 terminalId 和已固定 prepareRef 通过 `AppManage.development_enroll` 的 `owner_request` 登记原 prepareRef/draft/terminalId/endpoint。它只承接 live 预览,正式发布须另固定 snapshot 并登记正式运行合同;普通读取静态源码的变化不等于框架 dev-server/HMR 协议已经验收。
|
||||
|
||||
要将此服务改为 Docker release,须由作者实际选择并验证固定 Node 镜像 RepoDigest、声明全部 imageRefs,容器服务监听 `0.0.0.0` 而宿主只发布 `127.0.0.1`,并沿 AppManage.docker_prepare/docker_enroll 的实际 argv、nonce、CID 和 data mount 核验。当前文件是 native Node 教学样例,不能只改 delivery 类型就声称容器运行通过;镜像及 Docker 许可、安装与运维属于其实际外部 owner。
|
||||
114
skills/app-developer/examples/service-workbench/ui/app.js
Normal file
114
skills/app-developer/examples/service-workbench/ui/app.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { connectBridge } from './bridge.js'
|
||||
const element = id => document.getElementById(id)
|
||||
let bridge, connection, etag, notes = [], selected, rememberedNoteId, pendingSave, pendingWork, pendingViewState, viewRevision = 'none'
|
||||
const status = message => { element('status').textContent = message }
|
||||
function render() {
|
||||
element('notes').replaceChildren()
|
||||
for (const note of notes) {
|
||||
const row = document.createElement('li'), text = document.createElement('div'), button = document.createElement('button')
|
||||
text.textContent = note.text; button.textContent = selected?.id === note.id ? '已选中' : '选择此笔记'
|
||||
button.addEventListener('click', () => { selected = note; render() }); row.append(text,button); element('notes').append(row)
|
||||
}
|
||||
}
|
||||
async function read() {
|
||||
const result = await bridge.call('readData',{key:'notes'})
|
||||
const value = result.exists ? JSON.parse(result.content) : {schemaVersion:1,notes:[]}
|
||||
if (value.schemaVersion !== 1 || !Array.isArray(value.notes)) throw new Error('notes_format_unsupported')
|
||||
notes = value.notes; etag = result.etag; selected = notes.find(note => note.id === (selected?.id ?? rememberedNoteId)); render()
|
||||
status('已读取当前实例的数据。')
|
||||
}
|
||||
async function save() {
|
||||
if (!pendingSave) {
|
||||
const text = element('note').value.trim()
|
||||
if (!text) return status('先输入笔记。')
|
||||
pendingSave = {key:'notes',mutationId:crypto.randomUUID(),content:JSON.stringify({schemaVersion:1,notes:[...notes,{id:crypto.randomUUID(),revision:'1',text}]}),
|
||||
...(etag ? {expectedETag:etag} : {expectedAbsent:true})}
|
||||
}
|
||||
try {
|
||||
const receipt = await bridge.call('saveData',pendingSave)
|
||||
if (receipt.status !== 'saved') throw new Error('save_result_unknown')
|
||||
pendingSave = undefined; element('recover').disabled = true; element('note').value = ''; await read(); status('保存回执已核验。')
|
||||
} catch (error) { element('recover').disabled = false; status(`保存尚未确认:${error.message}。保留原ID和正文,先查询原保存;未确认前不要添加另一条笔记。`) }
|
||||
}
|
||||
element('save').addEventListener('click',save)
|
||||
element('refresh').addEventListener('click',() => pendingSave ? status('还有结果不明的原保存,请先查询。') : read().catch(error => status(error.message)))
|
||||
element('recover').addEventListener('click',async () => {
|
||||
if (!pendingSave) return
|
||||
try {
|
||||
const result = await bridge.call('lookupDataMutation',{key:'notes',mutationId:pendingSave.mutationId})
|
||||
if (result.status === 'saved') { pendingSave = undefined; element('recover').disabled = true; element('note').value = ''; await read() }
|
||||
else status(result.status === 'absent' ? '原保存明确未发生。可以用“保存笔记”重试同ID和原正文,冲突时读取并人工合并。' : '原保存仍不明确,请保留当前页面和原ID。')
|
||||
} catch (error) { status(error.message) }
|
||||
})
|
||||
element('request').addEventListener('click',async () => {
|
||||
const agentId = element('agent').value.trim()
|
||||
if (!agentId || !selected) return element('work-status').textContent = '先选择笔记和实际Agent。'
|
||||
if (pendingWork) return element('work-status').textContent = '已有待查询请求,请沿原ID恢复。'
|
||||
pendingWork = {clientRequestId:crypto.randomUUID(),userMessageId:crypto.randomUUID(),agentId,
|
||||
prompt:`请讨论这条笔记并给出可执行建议:${selected.text}`,scopeDescription:'读取notes来源文件核验所选笔记,只讨论该记录,不修改其他记录。',
|
||||
selection:{schemaVersion:1,selectionRevision:crypto.randomUUID(),summary:`笔记ID:${selected.id},记录版本:${selected.revision},内容:${selected.text};正式实例对象引用授予读取notes来源文件。`,objects:connection.application && etag ?
|
||||
[{schemaVersion:1,...connection.application,namespace:'data-files',objectId:'notes',revision:etag}] : []}}
|
||||
element('work').disabled = false
|
||||
try {
|
||||
const proposal = await bridge.call('prepareWork',pendingWork)
|
||||
element('work-status').textContent = `提案已准备,等待平台控件接纳。提案引用:${proposal.proposalRef}`
|
||||
} catch (error) { element('work-status').textContent = `提案/请求结果尚未确认:${error.message};保留原请求ID查询。` }
|
||||
})
|
||||
element('work').addEventListener('click',async () => {
|
||||
if (!pendingWork) return
|
||||
try { element('work-status').textContent = JSON.stringify(await bridge.call('lookupRequest',{agentId:pendingWork.agentId,clientRequestId:pendingWork.clientRequestId}),null,2) }
|
||||
catch (error) { element('work-status').textContent = error.message }
|
||||
})
|
||||
element('progress').addEventListener('click',async () => {
|
||||
if (!pendingWork) return
|
||||
try {
|
||||
const snapshot = await bridge.call('readWork',{agentId:pendingWork.agentId,clientRequestId:pendingWork.clientRequestId})
|
||||
element('work-status').textContent = `准入:${snapshot.admission};执行:${snapshot.execution}。执行完成后仍需核读实际成果。`
|
||||
element('cancel').disabled = !connection.capabilities?.includes('controlWork') || !snapshot.availableActions?.includes('abort')
|
||||
element('results').replaceChildren()
|
||||
for (const [index, resource] of (snapshot.resources ?? []).entries()) {
|
||||
const row = document.createElement('li'), button = document.createElement('button')
|
||||
button.textContent = `${resource.role === 'evidence' ? '查看证据' : '打开成果'} ${index + 1}`
|
||||
button.disabled = !connection.capabilities?.includes('openResource')
|
||||
button.addEventListener('click',async () => {
|
||||
try {
|
||||
const result = await bridge.call('openResource',{locator:resource.locator})
|
||||
element('work-status').textContent = result.state === 'navigation-requested' ? '已请求平台定位该成果,文件正文继续由原授权入口核验。' : result.opened === true ? '平台已打开获授权对象。' : '定位结果尚未确认。'
|
||||
} catch (error) { element('work-status').textContent = `成果未打开:${error.message}` }
|
||||
})
|
||||
row.append(button); element('results').append(row)
|
||||
}
|
||||
} catch (error) { element('work-status').textContent = `进度暂不可读:${error.message}` }
|
||||
})
|
||||
element('cancel').addEventListener('click',async () => {
|
||||
if (!pendingWork) return
|
||||
try {
|
||||
const receipt = await bridge.call('controlWork',{agentId:pendingWork.agentId,clientRequestId:pendingWork.clientRequestId,action:'abort'})
|
||||
element('work-status').textContent = `原工作停止结果:${receipt.status}。requested 仅表示停止意图已接纳,结果未知时沿原请求查询。`
|
||||
} catch (error) { element('work-status').textContent = `停止未确认:${error.message}` }
|
||||
})
|
||||
element('remember').addEventListener('click',async () => {
|
||||
if (!pendingViewState) pendingViewState = {schemaVersion:1,key:'selection',state:JSON.stringify({selectedNoteId:selected?.id ?? null}),
|
||||
persistence:'device',expectedRevision:viewRevision,mutationId:crypto.randomUUID()}
|
||||
try {
|
||||
const receipt = await bridge.call('saveViewState',pendingViewState)
|
||||
if (receipt.persistence !== 'device' || typeof receipt.revision !== 'string') throw new Error('view_save_result_unknown')
|
||||
viewRevision = receipt.revision; pendingViewState = undefined
|
||||
element('view-status').textContent = '已在这台设备记住所选位置。笔记正文仍由实例数据单独保存。'
|
||||
} catch (error) { element('view-status').textContent = `位置保存未确认:${error.message}。再次点击重试原ID、正文与修订,不覆盖其他更新。` }
|
||||
})
|
||||
try {
|
||||
bridge = await connectBridge(); connection = await bridge.call('connect')
|
||||
const remembered = connection.viewStates?.find(value => value.key === 'selection' && value.persistence === 'device')
|
||||
if (remembered) {
|
||||
viewRevision = remembered.revision
|
||||
try { const state = JSON.parse(remembered.state); if (typeof state.selectedNoteId === 'string') rememberedNoteId = state.selectedNoteId } catch { element('view-status').textContent = '此前位置格式不可读,已保留原偏好。' }
|
||||
}
|
||||
bridge.onEvent(event => { if (event?.type === 'work-accepted' && event.clientRequestId === pendingWork?.clientRequestId)
|
||||
element('work-status').textContent = `平台接纳回执:${JSON.stringify(event.result)}。通过原请求继续查询,接纳不等于业务完成。` })
|
||||
await read(); element('save').disabled = !connection.capabilities?.includes('saveData'); element('refresh').disabled = false
|
||||
element('request').disabled = !connection.capabilities?.includes('requestWork')
|
||||
element('progress').disabled = !connection.capabilities?.includes('readWork')
|
||||
element('remember').disabled = !connection.capabilities?.includes('saveViewState') || connection.deviceViewState?.state !== 'available'
|
||||
if (element('remember').disabled) element('view-status').textContent = `设备位置记忆不可用:${connection.deviceViewState?.reason ?? '未授权'}。业务笔记仍可按实际数据能力保存。`
|
||||
} catch (error) { status(`当前持久数据或协作能力不可用:${error.message}。未保存任何业务数据。`) }
|
||||
46
skills/app-developer/examples/service-workbench/ui/bridge.js
Normal file
46
skills/app-developer/examples/service-workbench/ui/bridge.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/** 教学SDK适配:桌面有限preload;Web严格parent来源/nonce握手,没有管理token或任意invoke。 */
|
||||
const methods = new Set(['connect','capabilities','publishSelection','prepareWork','lookupRequest','readWork','controlWork','openResource','readData','saveData','lookupDataMutation','saveViewState'])
|
||||
export async function connectBridge() {
|
||||
if (window.desirecoreApp?.call) return window.desirecoreApp
|
||||
if (window.parent === window) throw new Error('app_bridge_host_unavailable')
|
||||
let origin
|
||||
try { origin = new URL(document.referrer).origin } catch { throw new Error('app_bridge_host_origin_required') }
|
||||
if (origin === 'null' || origin === location.origin) throw new Error('app_bridge_independent_origin_required')
|
||||
return new Promise((resolve, reject) => {
|
||||
let nonce
|
||||
const timer = setTimeout(() => { window.removeEventListener('message', receive); reject(new Error('app_bridge_host_unavailable')) }, 15000)
|
||||
function receive(event) {
|
||||
if (event.source !== window.parent || event.origin !== origin || !event.data || typeof event.data !== 'object') return
|
||||
if (event.data.protocol === 'desirecore-app-hello-v1' && typeof event.data.nonce === 'string' && !nonce) {
|
||||
nonce = event.data.nonce
|
||||
window.parent.postMessage({ protocol:'desirecore-app-ready-v1',nonce },origin)
|
||||
} else if (nonce && event.data.protocol === 'desirecore-app-port-v1' && event.data.nonce === nonce && event.ports.length === 1) {
|
||||
window.removeEventListener('message',receive); clearTimeout(timer); resolve(portClient(event.ports[0]))
|
||||
}
|
||||
}
|
||||
window.addEventListener('message',receive)
|
||||
})
|
||||
}
|
||||
function portClient(port) {
|
||||
const pending = new Map(), listeners = new Set()
|
||||
port.onmessage = event => {
|
||||
const message = event.data
|
||||
if (message?.protocol !== 'desirecore-app-bridge-v1') return
|
||||
const request = pending.get(message.requestId)
|
||||
if (request) { pending.delete(message.requestId); clearTimeout(request.timer); message.error ? request.reject(new Error(message.error)) : request.resolve(message.result) }
|
||||
else if (message.event !== undefined) for (const listener of listeners) listener(message.event)
|
||||
}
|
||||
port.start()
|
||||
return {
|
||||
call(method,payload) {
|
||||
if (!methods.has(method)) return Promise.reject(new Error('app_bridge_method_rejected'))
|
||||
const requestId = crypto.randomUUID()
|
||||
return new Promise((resolve,reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('app_bridge_result_unknown')) },30000)
|
||||
pending.set(requestId,{resolve,reject,timer}); port.postMessage({ protocol:'desirecore-app-bridge-v1',requestId,method,payload })
|
||||
})
|
||||
},
|
||||
onEvent(callback) { listeners.add(callback); return () => listeners.delete(callback) },
|
||||
close() { for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('app_bridge_closed')) }; pending.clear(); listeners.clear(); port.close() },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>服务笔记工作台</title>
|
||||
<style>
|
||||
:root{font:16px/1.6 system-ui,sans-serif;color:#253447;background:#f1f5f9}body{max-width:920px;margin:40px auto;padding:0 24px}h1{font-size:30px;line-height:1.2}p{color:#526174}section{padding:24px;background:#fff;border:1px solid #dbe3ed;border-radius:16px;margin:20px 0}textarea{box-sizing:border-box;width:100%;min-height:140px;padding:14px;font:inherit;border:1px solid #becbdc;border-radius:8px}button,input{font:inherit;padding:9px 14px;border:1px solid #becbdc;border-radius:8px}button{background:#fff;cursor:pointer;margin:10px 6px 0 0}button.primary{background:#274c77;color:#fff;border-color:#274c77}button:disabled{opacity:.5;cursor:default}ul{padding:0;list-style:none}li{border-top:1px solid #e7edf4;padding:12px 0;white-space:pre-wrap}li button{margin-top:6px}output{display:block;margin-top:12px;color:#526174;white-space:pre-wrap;overflow-wrap:anywhere}label{display:block;margin-top:16px}input{max-width:100%;box-sizing:border-box;width:400px}@media(max-width:580px){body{margin:20px auto;padding:0 12px}section{padding:16px}}
|
||||
</style>
|
||||
<h1>服务笔记工作台</h1><p>先把想法记下来,再把选中的内容交给 Agent。业务数据与应用程序版本分别保存。</p>
|
||||
<section><label for="note">新笔记</label><textarea id="note" placeholder="输入一条笔记"></textarea>
|
||||
<button id="save" class="primary" disabled>保存笔记</button><button id="refresh" disabled>读取最新版本</button><button id="recover" disabled>查询原保存</button>
|
||||
<output id="status" aria-live="polite">正在连接应用宿主…</output><ul id="notes"></ul></section>
|
||||
<section><button id="remember" disabled>在这台设备记住所选位置</button><output id="view-status" aria-live="polite">只保存所选笔记的位置,不保存笔记正文。</output></section>
|
||||
<section><h2>与 Agent 协作</h2><p>教学示例使用实际 Agent ID;应用可以自行设计选择器。工作须由平台控件接纳。</p>
|
||||
<label for="agent">本次 Agent ID</label><input id="agent" placeholder="由当前授权的 Agent 配置提供">
|
||||
<button id="request" disabled>交给 Agent 讨论选中笔记</button><button id="work" disabled>查询原请求</button><button id="progress" disabled>读取原工作与成果</button><button id="cancel" disabled>停止原工作</button><output id="work-status" aria-live="polite">尚未准备请求。</output><ul id="results"></ul></section>
|
||||
<script type="module" src="./app.js"></script></html>
|
||||
13
skills/app-developer/examples/service-workbench/说明.md
Normal file
13
skills/app-developer/examples/service-workbench/说明.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 服务笔记工作台教学示例
|
||||
|
||||
这是可修改的固定网页+原生Node只读服务,不是正式市场条目。先读本Skill作者接口和runtime/运行说明.md,在真实目标环境按目标组合原 AppManage.prepare_release/runtime_enroll/commit_release 动作,纳管原执行合同、正式发布并核验。缺TaskHost owner或真实身份时保持不可用,不能重标为静态网页来绕过。
|
||||
|
||||
笔记仍通过应用桥的notes key持久化,用户、实例和预览各有独立scope;Node端不写个人数据或固定程序目录。网页可在本源 `/api/summary?count=<当前笔记条数>` 请求只读统计;这个能力不使用平台token,也不接收任意文件路径或SQL。
|
||||
|
||||
与Agent协作、保存超时恢复及更新/data合同遵循静态示例的相同约定。应用是否能运行与外部Node环境、原执行owner和当前用户授权有关。包不附带用户数据库、.env、平台身份或proof值。分发必须取得真实不可变来源和目录审阅回执;第二用户安装需独立环境,不共享开发者进程或业务数据。
|
||||
|
||||
工作查询保留原 agentId 和 clientRequestId;只有 readWork 返回的真实 Task 成果 locator 才可交 openResource 定位,navigation-requested 不等于文件正文已打开。当前 controlWork 只按 availableActions 支持 abort;未关联 Task 的工作成果列表可以为空,不凭 UI 编造 Task。
|
||||
|
||||
用户可显式选择“在这台设备记住所选位置”。示例 viewStateContract 只允许 selection 键并限制 1024 字节,不保存笔记正文;需实际 saveViewState grant,设备保存保持原 mutationId、expectedRevision 和正文。能力缺失时保持业务数据与视图偏好分别报告,预览不借正式设备状态。
|
||||
|
||||
用户要求分发时,用原 AppManage.export_release 的 owner_request 显式导出全部 payload 与 app-release.json;另给原工具真实写权限内的 export_file_ref,首次 expected_absent:true,已有目标用原 expected_etag。返回 archiveFile 的实际 fileRef/sha256/size/etag,核验 sha256/size 与 export 的 archiveDigest/size 一致后交原授权上传工具。取得真实固定来源再用 distribution_prepare 的 owner_request,registryRepoId 从 authoring_capabilities.registryTargetIds 选本次实际目标,生成三文件草稿;它不上传、不提交目录,也不改变个人安装身份。普通 HTTP 不自动注入平台凭据,不能读取实例 token 或绕过认证。
|
||||
9
skills/app-developer/examples/static-workbench/LICENSE
Normal file
9
skills/app-developer/examples/static-workbench/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DesireCore
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
141
skills/app-developer/examples/static-workbench/app-release.json
Normal file
141
skills/app-developer/examples/static-workbench/app-release.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"canonicalization": "app-release-c14n-v1",
|
||||
"definition": {
|
||||
"id": "personal-notes-workbench",
|
||||
"name": "个人笔记工作台",
|
||||
"version": "1.0.0",
|
||||
"productKind": "application",
|
||||
"icon": "Notebook",
|
||||
"iconLetter": "记",
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "笔记",
|
||||
"kind": "embedded-web",
|
||||
"serviceRef": "workbench:main"
|
||||
}
|
||||
],
|
||||
"placementPolicy": {
|
||||
"showInMyApps": true,
|
||||
"allowNavPin": true,
|
||||
"allowAgentBinding": true
|
||||
}
|
||||
},
|
||||
"delivery": {
|
||||
"type": "web-bundle",
|
||||
"entrypoints": [
|
||||
{
|
||||
"entrypointId": "main",
|
||||
"path": "ui/index.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"role": "license",
|
||||
"sha256": "e863481f423546e8984c07ffa00609d338b7f7533f60e89392301028163aa89e",
|
||||
"size": 1067
|
||||
},
|
||||
{
|
||||
"path": "application.json",
|
||||
"role": "definition",
|
||||
"sha256": "6f173d257bbb07d85deefe203a18741ec31fed27f244dbce6e37ef42f6662a9a",
|
||||
"size": 371
|
||||
},
|
||||
{
|
||||
"path": "data-contract.json",
|
||||
"role": "data-contract",
|
||||
"sha256": "e195b2fd60a2798141c4c75ab57e0927d115bfba7888b0881b69dffa9dcb26cc",
|
||||
"size": 266
|
||||
},
|
||||
{
|
||||
"path": "ui/app.js",
|
||||
"role": "ui",
|
||||
"sha256": "5ece9785b32efa8d30531354a8775b93ee1c4b6a470ffc37e86625d9c01b2b35",
|
||||
"size": 9111
|
||||
},
|
||||
{
|
||||
"path": "ui/bridge.js",
|
||||
"role": "ui",
|
||||
"sha256": "b227966a71d10002fadc2fba8d278ef85b7ab6aa4bb86afca4599734809bcb63",
|
||||
"size": 2984
|
||||
},
|
||||
{
|
||||
"path": "ui/index.html",
|
||||
"role": "ui",
|
||||
"sha256": "83de1fc2216834dc4326d607af45f2174609772b08a03acf6a98197363eec5a8",
|
||||
"size": 2624
|
||||
},
|
||||
{
|
||||
"path": "说明.md",
|
||||
"role": "documentation",
|
||||
"sha256": "672c14b8ca2b0f63c006762e2e296a027894a2f49fc447665ce55960f5bbcba2",
|
||||
"size": 3352
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "worktree",
|
||||
"sha256": "a28c1ce637a81f4695c6cd642fc202553bdaf79ad9f8d4b790dd9925786c1516"
|
||||
},
|
||||
"supportedPlatforms": [
|
||||
"darwin",
|
||||
"win32",
|
||||
"linux",
|
||||
"web"
|
||||
],
|
||||
"minimumClientVersion": "10.0.180",
|
||||
"requiredCapabilities": [
|
||||
"app-authoring-v1",
|
||||
"release",
|
||||
"web-bundle",
|
||||
"bridge",
|
||||
"data",
|
||||
"view-state"
|
||||
],
|
||||
"dataContract": {
|
||||
"schemaVersion": 1,
|
||||
"formatVersion": "notes-v1",
|
||||
"readableVersions": [
|
||||
"notes-v1"
|
||||
],
|
||||
"writableVersions": [
|
||||
"notes-v1"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"key": "notes",
|
||||
"path": "notes.json",
|
||||
"maxBytes": 131072,
|
||||
"contentType": "application/json"
|
||||
}
|
||||
],
|
||||
"uninstallRetention": "retain"
|
||||
},
|
||||
"bridgeCapabilities": [
|
||||
"connect",
|
||||
"capabilities",
|
||||
"readData",
|
||||
"saveData",
|
||||
"lookupDataMutation",
|
||||
"publishSelection",
|
||||
"requestWork",
|
||||
"lookupRequest",
|
||||
"readWork",
|
||||
"controlWork",
|
||||
"openResource",
|
||||
"readResource",
|
||||
"saveViewState"
|
||||
],
|
||||
"viewStateContract": {
|
||||
"schemaVersion": 1,
|
||||
"keys": [
|
||||
"selection"
|
||||
],
|
||||
"maxBytes": 1024,
|
||||
"formatVersion": "1"
|
||||
},
|
||||
"instructionsPath": "说明.md",
|
||||
"releaseDigest": "87171e43d8196120556f97ff02813fad04e362ffdfd18e12851750b4124d071b"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "personal-notes-workbench", "name": "个人笔记工作台", "version": "1.0.0", "productKind": "application",
|
||||
"icon": "Notebook", "iconLetter": "记",
|
||||
"entrypoints": [{ "id": "main", "name": "笔记", "kind": "embedded-web", "serviceRef": "workbench:main" }],
|
||||
"placementPolicy": { "showInMyApps": true, "allowNavPin": true, "allowAgentBinding": true }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schemaVersion": 1, "formatVersion": "notes-v1", "readableVersions": ["notes-v1"], "writableVersions": ["notes-v1"],
|
||||
"files": [{ "key": "notes", "path": "notes.json", "maxBytes": 131072, "contentType": "application/json" }],
|
||||
"uninstallRetention": "retain"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"description": "显式收录教学程序材料,业务notes.json只属于实例数据根,不进入发行包。",
|
||||
"delivery": {
|
||||
"type": "web-bundle",
|
||||
"entrypoints": [
|
||||
{
|
||||
"entrypointId": "main",
|
||||
"path": "ui/index.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"supportedPlatforms": [
|
||||
"darwin",
|
||||
"win32",
|
||||
"linux",
|
||||
"web"
|
||||
],
|
||||
"requiredCapabilities": [
|
||||
"app-authoring-v1",
|
||||
"release",
|
||||
"web-bundle",
|
||||
"bridge",
|
||||
"data",
|
||||
"view-state"
|
||||
],
|
||||
"bridgeCapabilities": [
|
||||
"connect",
|
||||
"capabilities",
|
||||
"readData",
|
||||
"saveData",
|
||||
"lookupDataMutation",
|
||||
"publishSelection",
|
||||
"requestWork",
|
||||
"lookupRequest",
|
||||
"readWork",
|
||||
"controlWork",
|
||||
"openResource",
|
||||
"readResource",
|
||||
"saveViewState"
|
||||
],
|
||||
"dataContractPath": "data-contract.json",
|
||||
"files": [
|
||||
{
|
||||
"path": "application.json",
|
||||
"role": "definition"
|
||||
},
|
||||
{
|
||||
"path": "ui/index.html",
|
||||
"role": "ui"
|
||||
},
|
||||
{
|
||||
"path": "ui/bridge.js",
|
||||
"role": "ui"
|
||||
},
|
||||
{
|
||||
"path": "ui/app.js",
|
||||
"role": "ui"
|
||||
},
|
||||
{
|
||||
"path": "data-contract.json",
|
||||
"role": "data-contract"
|
||||
},
|
||||
{
|
||||
"path": "说明.md",
|
||||
"role": "documentation"
|
||||
},
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"role": "license"
|
||||
}
|
||||
],
|
||||
"viewStateContract": {
|
||||
"schemaVersion": 1,
|
||||
"keys": [
|
||||
"selection"
|
||||
],
|
||||
"maxBytes": 1024,
|
||||
"formatVersion": "1"
|
||||
}
|
||||
}
|
||||
114
skills/app-developer/examples/static-workbench/ui/app.js
Normal file
114
skills/app-developer/examples/static-workbench/ui/app.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { connectBridge } from './bridge.js'
|
||||
const element = id => document.getElementById(id)
|
||||
let bridge, connection, etag, notes = [], selected, rememberedNoteId, pendingSave, pendingWork, pendingViewState, viewRevision = 'none'
|
||||
const status = message => { element('status').textContent = message }
|
||||
function render() {
|
||||
element('notes').replaceChildren()
|
||||
for (const note of notes) {
|
||||
const row = document.createElement('li'), text = document.createElement('div'), button = document.createElement('button')
|
||||
text.textContent = note.text; button.textContent = selected?.id === note.id ? '已选中' : '选择此笔记'
|
||||
button.addEventListener('click', () => { selected = note; render() }); row.append(text,button); element('notes').append(row)
|
||||
}
|
||||
}
|
||||
async function read() {
|
||||
const result = await bridge.call('readData',{key:'notes'})
|
||||
const value = result.exists ? JSON.parse(result.content) : {schemaVersion:1,notes:[]}
|
||||
if (value.schemaVersion !== 1 || !Array.isArray(value.notes)) throw new Error('notes_format_unsupported')
|
||||
notes = value.notes; etag = result.etag; selected = notes.find(note => note.id === (selected?.id ?? rememberedNoteId)); render()
|
||||
status('已读取当前实例的数据。')
|
||||
}
|
||||
async function save() {
|
||||
if (!pendingSave) {
|
||||
const text = element('note').value.trim()
|
||||
if (!text) return status('先输入笔记。')
|
||||
pendingSave = {key:'notes',mutationId:crypto.randomUUID(),content:JSON.stringify({schemaVersion:1,notes:[...notes,{id:crypto.randomUUID(),revision:'1',text}]}),
|
||||
...(etag ? {expectedETag:etag} : {expectedAbsent:true})}
|
||||
}
|
||||
try {
|
||||
const receipt = await bridge.call('saveData',pendingSave)
|
||||
if (receipt.status !== 'saved') throw new Error('save_result_unknown')
|
||||
pendingSave = undefined; element('recover').disabled = true; element('note').value = ''; await read(); status('保存回执已核验。')
|
||||
} catch (error) { element('recover').disabled = false; status(`保存尚未确认:${error.message}。保留原ID和正文,先查询原保存;未确认前不要添加另一条笔记。`) }
|
||||
}
|
||||
element('save').addEventListener('click',save)
|
||||
element('refresh').addEventListener('click',() => pendingSave ? status('还有结果不明的原保存,请先查询。') : read().catch(error => status(error.message)))
|
||||
element('recover').addEventListener('click',async () => {
|
||||
if (!pendingSave) return
|
||||
try {
|
||||
const result = await bridge.call('lookupDataMutation',{key:'notes',mutationId:pendingSave.mutationId})
|
||||
if (result.status === 'saved') { pendingSave = undefined; element('recover').disabled = true; element('note').value = ''; await read() }
|
||||
else status(result.status === 'absent' ? '原保存明确未发生。可以用“保存笔记”重试同ID和原正文,冲突时读取并人工合并。' : '原保存仍不明确,请保留当前页面和原ID。')
|
||||
} catch (error) { status(error.message) }
|
||||
})
|
||||
element('request').addEventListener('click',async () => {
|
||||
const agentId = element('agent').value.trim()
|
||||
if (!agentId || !selected) return element('work-status').textContent = '先选择笔记和实际Agent。'
|
||||
if (pendingWork) return element('work-status').textContent = '已有待查询请求,请沿原ID恢复。'
|
||||
pendingWork = {clientRequestId:crypto.randomUUID(),userMessageId:crypto.randomUUID(),agentId,
|
||||
prompt:`请讨论这条笔记并给出可执行建议:${selected.text}`,scopeDescription:'读取notes来源文件核验所选笔记,只讨论该记录,不修改其他记录。',
|
||||
selection:{schemaVersion:1,selectionRevision:crypto.randomUUID(),summary:`笔记ID:${selected.id},记录版本:${selected.revision},内容:${selected.text};正式实例对象引用授予读取notes来源文件。`,objects:connection.application && etag ?
|
||||
[{schemaVersion:1,...connection.application,namespace:'data-files',objectId:'notes',revision:etag}] : []}}
|
||||
element('work').disabled = false
|
||||
try {
|
||||
const proposal = await bridge.call('prepareWork',pendingWork)
|
||||
element('work-status').textContent = `提案已准备,等待平台控件接纳。提案引用:${proposal.proposalRef}`
|
||||
} catch (error) { element('work-status').textContent = `提案/请求结果尚未确认:${error.message};保留原请求ID查询。` }
|
||||
})
|
||||
element('work').addEventListener('click',async () => {
|
||||
if (!pendingWork) return
|
||||
try { element('work-status').textContent = JSON.stringify(await bridge.call('lookupRequest',{agentId:pendingWork.agentId,clientRequestId:pendingWork.clientRequestId}),null,2) }
|
||||
catch (error) { element('work-status').textContent = error.message }
|
||||
})
|
||||
element('progress').addEventListener('click',async () => {
|
||||
if (!pendingWork) return
|
||||
try {
|
||||
const snapshot = await bridge.call('readWork',{agentId:pendingWork.agentId,clientRequestId:pendingWork.clientRequestId})
|
||||
element('work-status').textContent = `准入:${snapshot.admission};执行:${snapshot.execution}。执行完成后仍需核读实际成果。`
|
||||
element('cancel').disabled = !connection.capabilities?.includes('controlWork') || !snapshot.availableActions?.includes('abort')
|
||||
element('results').replaceChildren()
|
||||
for (const [index, resource] of (snapshot.resources ?? []).entries()) {
|
||||
const row = document.createElement('li'), button = document.createElement('button')
|
||||
button.textContent = `${resource.role === 'evidence' ? '查看证据' : '打开成果'} ${index + 1}`
|
||||
button.disabled = !connection.capabilities?.includes('openResource')
|
||||
button.addEventListener('click',async () => {
|
||||
try {
|
||||
const result = await bridge.call('openResource',{locator:resource.locator})
|
||||
element('work-status').textContent = result.state === 'navigation-requested' ? '已请求平台定位该成果,文件正文继续由原授权入口核验。' : result.opened === true ? '平台已打开获授权对象。' : '定位结果尚未确认。'
|
||||
} catch (error) { element('work-status').textContent = `成果未打开:${error.message}` }
|
||||
})
|
||||
row.append(button); element('results').append(row)
|
||||
}
|
||||
} catch (error) { element('work-status').textContent = `进度暂不可读:${error.message}` }
|
||||
})
|
||||
element('cancel').addEventListener('click',async () => {
|
||||
if (!pendingWork) return
|
||||
try {
|
||||
const receipt = await bridge.call('controlWork',{agentId:pendingWork.agentId,clientRequestId:pendingWork.clientRequestId,action:'abort'})
|
||||
element('work-status').textContent = `原工作停止结果:${receipt.status}。requested 仅表示停止意图已接纳,结果未知时沿原请求查询。`
|
||||
} catch (error) { element('work-status').textContent = `停止未确认:${error.message}` }
|
||||
})
|
||||
element('remember').addEventListener('click',async () => {
|
||||
if (!pendingViewState) pendingViewState = {schemaVersion:1,key:'selection',state:JSON.stringify({selectedNoteId:selected?.id ?? null}),
|
||||
persistence:'device',expectedRevision:viewRevision,mutationId:crypto.randomUUID()}
|
||||
try {
|
||||
const receipt = await bridge.call('saveViewState',pendingViewState)
|
||||
if (receipt.persistence !== 'device' || typeof receipt.revision !== 'string') throw new Error('view_save_result_unknown')
|
||||
viewRevision = receipt.revision; pendingViewState = undefined
|
||||
element('view-status').textContent = '已在这台设备记住所选位置。笔记正文仍由实例数据单独保存。'
|
||||
} catch (error) { element('view-status').textContent = `位置保存未确认:${error.message}。再次点击重试原ID、正文与修订,不覆盖其他更新。` }
|
||||
})
|
||||
try {
|
||||
bridge = await connectBridge(); connection = await bridge.call('connect')
|
||||
const remembered = connection.viewStates?.find(value => value.key === 'selection' && value.persistence === 'device')
|
||||
if (remembered) {
|
||||
viewRevision = remembered.revision
|
||||
try { const state = JSON.parse(remembered.state); if (typeof state.selectedNoteId === 'string') rememberedNoteId = state.selectedNoteId } catch { element('view-status').textContent = '此前位置格式不可读,已保留原偏好。' }
|
||||
}
|
||||
bridge.onEvent(event => { if (event?.type === 'work-accepted' && event.clientRequestId === pendingWork?.clientRequestId)
|
||||
element('work-status').textContent = `平台接纳回执:${JSON.stringify(event.result)}。通过原请求继续查询,接纳不等于业务完成。` })
|
||||
await read(); element('save').disabled = !connection.capabilities?.includes('saveData'); element('refresh').disabled = false
|
||||
element('request').disabled = !connection.capabilities?.includes('requestWork')
|
||||
element('progress').disabled = !connection.capabilities?.includes('readWork')
|
||||
element('remember').disabled = !connection.capabilities?.includes('saveViewState') || connection.deviceViewState?.state !== 'available'
|
||||
if (element('remember').disabled) element('view-status').textContent = `设备位置记忆不可用:${connection.deviceViewState?.reason ?? '未授权'}。业务笔记仍可按实际数据能力保存。`
|
||||
} catch (error) { status(`当前持久数据或协作能力不可用:${error.message}。未保存任何业务数据。`) }
|
||||
46
skills/app-developer/examples/static-workbench/ui/bridge.js
Normal file
46
skills/app-developer/examples/static-workbench/ui/bridge.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/** 教学SDK适配:桌面有限preload;Web严格parent来源/nonce握手,没有管理token或任意invoke。 */
|
||||
const methods = new Set(['connect','capabilities','publishSelection','prepareWork','lookupRequest','readWork','controlWork','openResource','readData','saveData','lookupDataMutation','saveViewState'])
|
||||
export async function connectBridge() {
|
||||
if (window.desirecoreApp?.call) return window.desirecoreApp
|
||||
if (window.parent === window) throw new Error('app_bridge_host_unavailable')
|
||||
let origin
|
||||
try { origin = new URL(document.referrer).origin } catch { throw new Error('app_bridge_host_origin_required') }
|
||||
if (origin === 'null' || origin === location.origin) throw new Error('app_bridge_independent_origin_required')
|
||||
return new Promise((resolve, reject) => {
|
||||
let nonce
|
||||
const timer = setTimeout(() => { window.removeEventListener('message', receive); reject(new Error('app_bridge_host_unavailable')) }, 15000)
|
||||
function receive(event) {
|
||||
if (event.source !== window.parent || event.origin !== origin || !event.data || typeof event.data !== 'object') return
|
||||
if (event.data.protocol === 'desirecore-app-hello-v1' && typeof event.data.nonce === 'string' && !nonce) {
|
||||
nonce = event.data.nonce
|
||||
window.parent.postMessage({ protocol:'desirecore-app-ready-v1',nonce },origin)
|
||||
} else if (nonce && event.data.protocol === 'desirecore-app-port-v1' && event.data.nonce === nonce && event.ports.length === 1) {
|
||||
window.removeEventListener('message',receive); clearTimeout(timer); resolve(portClient(event.ports[0]))
|
||||
}
|
||||
}
|
||||
window.addEventListener('message',receive)
|
||||
})
|
||||
}
|
||||
function portClient(port) {
|
||||
const pending = new Map(), listeners = new Set()
|
||||
port.onmessage = event => {
|
||||
const message = event.data
|
||||
if (message?.protocol !== 'desirecore-app-bridge-v1') return
|
||||
const request = pending.get(message.requestId)
|
||||
if (request) { pending.delete(message.requestId); clearTimeout(request.timer); message.error ? request.reject(new Error(message.error)) : request.resolve(message.result) }
|
||||
else if (message.event !== undefined) for (const listener of listeners) listener(message.event)
|
||||
}
|
||||
port.start()
|
||||
return {
|
||||
call(method,payload) {
|
||||
if (!methods.has(method)) return Promise.reject(new Error('app_bridge_method_rejected'))
|
||||
const requestId = crypto.randomUUID()
|
||||
return new Promise((resolve,reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('app_bridge_result_unknown')) },30000)
|
||||
pending.set(requestId,{resolve,reject,timer}); port.postMessage({ protocol:'desirecore-app-bridge-v1',requestId,method,payload })
|
||||
})
|
||||
},
|
||||
onEvent(callback) { listeners.add(callback); return () => listeners.delete(callback) },
|
||||
close() { for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('app_bridge_closed')) }; pending.clear(); listeners.clear(); port.close() },
|
||||
}
|
||||
}
|
||||
15
skills/app-developer/examples/static-workbench/ui/index.html
Normal file
15
skills/app-developer/examples/static-workbench/ui/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>个人笔记工作台</title>
|
||||
<style>
|
||||
:root{font:16px/1.6 system-ui,sans-serif;color:#253447;background:#f1f5f9}body{max-width:920px;margin:40px auto;padding:0 24px}h1{font-size:30px;line-height:1.2}p{color:#526174}section{padding:24px;background:#fff;border:1px solid #dbe3ed;border-radius:16px;margin:20px 0}textarea{box-sizing:border-box;width:100%;min-height:140px;padding:14px;font:inherit;border:1px solid #becbdc;border-radius:8px}button,input{font:inherit;padding:9px 14px;border:1px solid #becbdc;border-radius:8px}button{background:#fff;cursor:pointer;margin:10px 6px 0 0}button.primary{background:#274c77;color:#fff;border-color:#274c77}button:disabled{opacity:.5;cursor:default}ul{padding:0;list-style:none}li{border-top:1px solid #e7edf4;padding:12px 0;white-space:pre-wrap}li button{margin-top:6px}output{display:block;margin-top:12px;color:#526174;white-space:pre-wrap;overflow-wrap:anywhere}label{display:block;margin-top:16px}input{max-width:100%;box-sizing:border-box;width:400px}@media(max-width:580px){body{margin:20px auto;padding:0 12px}section{padding:16px}}
|
||||
</style>
|
||||
<h1>个人笔记工作台</h1><p>先把想法记下来,再把选中的内容交给 Agent。业务数据与应用程序版本分别保存。</p>
|
||||
<section><label for="note">新笔记</label><textarea id="note" placeholder="输入一条笔记"></textarea>
|
||||
<button id="save" class="primary" disabled>保存笔记</button><button id="refresh" disabled>读取最新版本</button><button id="recover" disabled>查询原保存</button>
|
||||
<output id="status" aria-live="polite">正在连接应用宿主…</output><ul id="notes"></ul></section>
|
||||
<section><button id="remember" disabled>在这台设备记住所选位置</button><output id="view-status" aria-live="polite">只保存所选笔记的位置,不保存笔记正文。</output></section>
|
||||
<section><h2>与 Agent 协作</h2><p>教学示例使用实际 Agent ID;应用可以自行设计选择器。工作须由平台控件接纳。</p>
|
||||
<label for="agent">本次 Agent ID</label><input id="agent" placeholder="由当前授权的 Agent 配置提供">
|
||||
<button id="request" disabled>交给 Agent 讨论选中笔记</button><button id="work" disabled>查询原请求</button><button id="progress" disabled>读取原工作与成果</button><button id="cancel" disabled>停止原工作</button><output id="work-status" aria-live="polite">尚未准备请求。</output><ul id="results"></ul></section>
|
||||
<script type="module" src="./app.js"></script></html>
|
||||
17
skills/app-developer/examples/static-workbench/说明.md
Normal file
17
skills/app-developer/examples/static-workbench/说明.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 个人笔记工作台教学示例
|
||||
|
||||
这是可修改的最小材料,不是正式市场条目或已经完成的真实 Agent 验收。将示例复制到任务实际授权工作目录,用本 Skill 的 create-release.mjs 生成 app-release.json,再按目标组合原 AppManage.prepare_release/preview_prepare/commit_release 动作,准备固定材料、snapshot 预览与个人发布。
|
||||
|
||||
笔记保存在声明的 notes key,由平台按用户/实例/预览分配专用数据根。预览数据不自动成为正式数据;包中没有 notes.json。裸浏览器打开可看到页面,缺少可信桥时不会假装已持久保存。Web 须有独立 app origin 和真实 parent MessagePort;同源或无来源时桥拒绝。
|
||||
|
||||
保存使用 expectedETag/expectedAbsent;结果不明保留原 mutationId 和正文,先查原回执。示例只在当前页面保留待查询 ID,完整产品应通过获授权的视图状态或自身数据合同保存可恢复的请求引用,不能持久化平台 token。
|
||||
|
||||
“交给 Agent”先准备提案。真正提交在平台控件上确认,再从 work-accepted 或同请求 lookup 得到真实接纳/Task 状态;页面不能自己签 intent。正式实例引用已声明notes来源文件:namespace为data-files、objectId为notes、revision为readData的真实ETag,平台不猜测notes JSON内部业务schema。选中笔记的稳定id、记录版本及可见文字留在summary;授权范围明确包含读取来源文件并仅讨论所选记录。Agent读取真实文件后定位记录,不能把未实现的notes业务对象owner说成已支持。预览只发讨论摘要,不借正式实例身份。
|
||||
|
||||
更新保持原安装/data scope并先核验 notes-v1兼容;程序回退不恢复旧笔记。卸载默认保留业务数据。示例无需第三方服务,调用 Agent 仍取决于真实算力连接、权限和额度。平台能力未开放或未发行时保持不可用并说明原因。
|
||||
|
||||
工作查询保留原 agentId 和 clientRequestId;只有 readWork 返回的真实 Task 成果 locator 才可交 openResource 定位,navigation-requested 不等于文件正文已打开。当前 controlWork 只按 availableActions 支持 abort;未关联 Task 的工作成果列表可以为空,不凭 UI 编造 Task。
|
||||
|
||||
用户可显式选择“在这台设备记住所选位置”。示例 viewStateContract 只允许 selection 键并限制 1024 字节,不保存笔记正文;需实际 saveViewState grant,设备保存保持原 mutationId、expectedRevision 和正文。能力缺失时保持业务数据与视图偏好分别报告,预览不借正式设备状态。
|
||||
|
||||
用户要求分发时,用原 AppManage.export_release 的 owner_request 显式导出全部 payload 与 app-release.json;另给原工具真实写权限内的 export_file_ref,首次 expected_absent:true,已有目标用原 expected_etag。返回 archiveFile 的实际 fileRef/sha256/size/etag,核验 sha256/size 与 export 的 archiveDigest/size 一致后交原授权上传工具。取得真实固定来源再用 distribution_prepare 的 owner_request,registryRepoId 从 authoring_capabilities.registryTargetIds 选本次实际目标,生成三文件草稿;它不上传、不提交目录,也不改变个人安装身份。普通 HTTP 不自动注入平台凭据,不能读取实例 token 或绕过认证。
|
||||
Reference in New Issue
Block a user