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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/agent-document-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 应用 Agent 的 Skills 和工具设置

LocalApp 随 Server 提供四个公共文档 skill:MarkItDown、PDF、DOCX 和 XLSX。来源、固定 commit、许可证和 LocalApp 的改动记录分别保存在 `packages/server/skills/<name>/SOURCE.json` 与 `LICENSE` 中;发布包包含完整 instructions、scripts 和 references。

应用在 manifest 声明所需能力,例如:

```json
{ "agent": { "capabilities": ["files", "terminal", "skills"] } }
```

用户在应用设置页的 **Agent** 标签,或应用聊天区的 **应用 Agent 设置** 入口,选择自己的能力、公共 skills 和工具。其他用户分别保存自己的选择。公共 skills 默认不启用;能力仍需应用声明并经当前用户启用。已有工作区 `skills/` 中的自定义 skills 保持原有加载方式。

关闭单个工具后,Server 从模型可见的工具列表中移除它,并阻止直接调用。应用工具在应用注册工具时同步到当前用户的设置列表;旧版应用首次发起请求后也会同步。选择新能力并保存后,会列出相应 DSH 工具。更改设置会关闭该应用当前用户的 Agent 实例,下一次请求使用新配置;对话仍然保留。

模型供应商、凭据和 MCP 连接仍在 **模型与 Agent** 页配置。保存供应商不会覆盖应用的 Skills 和工具选择。

## 文档运行环境

Skills 的 Python 脚本已包含在包内,Python、第三方 Python 库、LibreOffice 和 Poppler 不随 npm 包提供。启用 skill 不等于这些依赖已安装。需要 Python 3.10+,并且该 Python 的安装目录必须能被现有应用终端沙箱读取。脚本、输入文件、输出文件、缓存和虚拟环境都在当前用户的应用工作区内运行。

可在工作区创建虚拟环境后安装需要的库:

```bash
python3 -m venv .venv
.venv/bin/python -m pip install 'markitdown[pdf,docx,xlsx]==0.1.8' pypdf reportlab pdfplumber python-docx openpyxl
```

Windows 的虚拟环境命令路径为 `.venv/Scripts/python.exe`,但当前终端能力仍取决于 Server 是否支持文件读取隔离。不得为了运行文档脚本绕过沙箱。

PDF、DOCX、XLSX skill 中的 `scripts/` 路径相对于 skill 目录,例如 `.localapp-public-skills/pdf/scripts/pdf_read.py`。优先提取本地文本和表格;扫描件需要 OCR,文档视觉检查需要页面渲染与可用的图片理解工具。外部 OCR、云转换和额外模型服务不会自动配置。

本次接入公共 skills 和设置选择,不增加聊天附件上传或模型图片输入传输。文件需已在 Agent 工作区内;这些传输能力需要另外接入。

### 系统文档处理环境

文档 Skills 由 LocalApp 提供。管理员在“系统设置 → Python 环境”检查全部文档 Skills 的 Python 3.10+ 和依赖,结果适用于所有用户和应用。普通用户不能访问系统检查接口或查看解释器路径;应用设置只显示 Skill 是否可用。

检查使用 Server 的 DSH 工作区执行器,不安装软件或请求模型,不依赖用户的模型配置。结果缓存 15 秒。依赖缺失时,应用内无法开启对应 Skill;后端保存时同样校验,并从 Agent 实际加载的列表中移除依赖失效的 Skill。

检查在同一个 Python 中导入依赖,不拼接不同解释器的依赖。PDF 渲染、LibreOffice 等可选依赖单独显示,不阻止基本 Skill 开启。环境就绪不代表附件上传、OCR 或真实文档处理已经通过验证。

### 通用 Python 虚拟环境

管理员可以选择已有 venv 的 Python 绝对路径,或指定 Python 3.10+ 的基础解释器创建 LocalApp 专用 venv。配置属于 Server,供所有应用的 Python 脚本共用,不限于文档处理。专用环境放在 `dataDir/python-environments/<id>`;配置保存在 `dataDir/python-environment.json`。

创建环境默认不安装文档库,可明确勾选安装,或稍后使用“安装文档 Skills 依赖”。安装只修改选定的虚拟环境,不向系统 Python 安装包。新建不覆盖现有目录;创建或安装失败不会替换当前环境。

执行器向命令提供虚拟环境的 PATH 和 VIRTUAL_ENV,并向模型说明解释器路径。环境及基础 Python 安装目录仅允许读取;应用工作区是可写目录,其他 Server 数据仍不能读取。配置变更后的后续 Agent 请求会重新创建运行时,使用新环境;环境检查只使用选定的解释器,不回退到其他 Python。
3 changes: 2 additions & 1 deletion packages/sdk-agent/src/agent-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { AgentInteraction, HarnessAgent } from "./harness-client.js";
export function AgentControls({ agent }: { agent: HarnessAgent | null }) {
const [providers, setProviders] = useState<Array<{ id: string; name: string; model: string }>>([]);
const [sessions, setSessions] = useState<Array<{ id: string; createdAt: number }>>([]);
const [settingsUrl, setSettingsUrl] = useState("/my/models/");
const [settingsUrl, setSettingsUrl] = useState("/my/models");
const [error, setError] = useState("");
const [, render] = useState(0);
useEffect(() => {
Expand All @@ -19,6 +19,7 @@ export function AgentControls({ agent }: { agent: HarnessAgent | null }) {
const act = (operation: Promise<unknown>) => { setError(""); operation.catch((e) => setError(e.message)); };
return <div className="space-y-2 border-b p-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
{agent && agent.appId !== "platform" && <a href={`${settingsUrl.replace(/\/my\/models\/?$/, "/my/agent")}?${new URLSearchParams({ appId: agent.appId })}`} target="_blank" rel="noreferrer">应用 Agent 设置</a>}
<a href={settingsUrl} target="_blank" rel="noreferrer">模型与 Agent 设置</a>
<select aria-label="模型供应商" value={agent?.providerId ?? ""} disabled={!agent || agent.state.isStreaming} onChange={(e) => { if (agent) { agent.providerId = e.target.value; agent.newSession(); agent.startBackgroundEvents(); act(agent.listSessions().then(setSessions)); render((n) => n + 1); } }}>
<option value="">选择模型</option>{providers.map((p) => <option key={p.id} value={p.id}>{p.name} · {p.model}</option>)}
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-agent/src/harness-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class HarnessAgent {
if (!response.ok || !body.success) throw new Error(body.error || "Agent 请求失败");
return body.data;
}
async registerTools() {
if (this.appId === "platform") return;
await this.api("app-tools", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ appId: this.appId, tools: this.state.tools.map(({ name, description }) => ({ name, description })) }) });
}
settings(): Promise<{ providers: Array<{ id: string; name: string; model: string }>; defaultProviderId: string; settingsUrl?: string }> { return this.api("settings"); }
listSessions(): Promise<Array<{ id: string; createdAt: number }>> { return this.api(`sessions?${new URLSearchParams({ appId: this.appId, ...(this.providerId ? { providerId: this.providerId } : {}) })}`); }
async selectSession(id: string) {
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-agent/src/use-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function useAgent(options?: UseAgentOptions): UseAgentReturn {
initialState: { systemPrompt },
});
agent.state.tools = tools;
void agent.registerTools().catch(() => {});

agent.subscribe((event: AgentEvent) => {
if (cancelled) return;
Expand Down
7 changes: 7 additions & 0 deletions packages/server/THIRD_PARTY_NOTICES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,10 @@ 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.

Public document skills bundled under skills/:
- PDF, DOCX, XLSX: NousResearch/hermes-agent, MIT.
- MarkItDown: K-Dense-AI/scientific-agent-skills, MIT.
Each bundle preserves LICENSE and SOURCE.json with its source commit.
LocalApp adapts workspace, dependency and tool guidance; Python dependencies
and optional external services are not bundled with these skills.
3 changes: 2 additions & 1 deletion packages/server/scripts/build-server-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export async function buildServerPackage(options = {}) {
await fs.mkdir(binDirectory, { recursive: true, mode: 0o755 });
await fs.copyFile(path.join(serverDirectory, "THIRD_PARTY_NOTICES.txt"), path.join(outputDirectory, "THIRD_PARTY_NOTICES.txt"));
await fs.cp(webOutput, webDirectory, { recursive: true });
await fs.cp(path.join(serverDirectory, "skills"), path.join(outputDirectory, "skills"), { recursive: true });
await fs.mkdir(runnerDirectory, { recursive: true, mode: 0o755 });
await fs.cp(path.join(serverDirectory, "runner/localapp-runner.mjs"), path.join(runnerDirectory, "localapp-runner.mjs"));

Expand All @@ -49,7 +50,7 @@ export async function buildServerPackage(options = {}) {
};
await build({ ...bundleOptions, entryPoints: [path.join(sourceDirectory, "package-cli.ts")], outfile: path.join(binDirectory, "server-cli.cjs") });
await build({ ...bundleOptions, entryPoints: [path.join(sourceDirectory, "package-worker.ts")], outfile: path.join(binDirectory, "worker.cjs") });
const launcher = `#!/usr/bin/env node\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst packageDirectory = path.dirname(fileURLToPath(import.meta.url));\nprocess.env.LOCALAPP_WORKER_PATH ??= path.join(packageDirectory, "worker.cjs");\nprocess.env.LOCALAPP_WEB_ROOT ??= path.resolve(packageDirectory, "../web");\nawait import("./server-cli.cjs");\n`;
const launcher = `#!/usr/bin/env node\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst packageDirectory = path.dirname(fileURLToPath(import.meta.url));\nprocess.env.LOCALAPP_WORKER_PATH ??= path.join(packageDirectory, "worker.cjs");\nprocess.env.LOCALAPP_PUBLIC_SKILLS_ROOT ??= path.resolve(packageDirectory, "../skills");\nprocess.env.LOCALAPP_WEB_ROOT ??= path.resolve(packageDirectory, "../web");\nawait import("./server-cli.cjs");\n`;
await fs.writeFile(path.join(binDirectory, "server.mjs"), launcher, { mode: 0o755 });
await fs.chmod(path.join(binDirectory, "server-cli.cjs"), 0o755);
await fs.chmod(path.join(binDirectory, "worker.cjs"), 0o755);
Expand Down
8 changes: 8 additions & 0 deletions packages/server/scripts/build-server-package.node-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ test("packed Node Server initializes and serves Web without repository dependenc
const artifact = await buildServerPackage({ outputDirectory });
const repeated = await buildServerPackage({ outputDirectory: repeatedDirectory });
assert.equal(artifact.bundleDigest, repeated.bundleDigest);
const manifest = JSON.parse(await fs.readFile(artifact.manifestPath, "utf8"));
for (const skill of ["markitdown", "pdf", "docx", "xlsx"]) {
assert.ok(manifest.files[`skills/${skill}/SKILL.md`]);
assert.ok(manifest.files[`skills/${skill}/LICENSE`]);
assert.ok(manifest.files[`skills/${skill}/SOURCE.json`]);
assert.ok(Object.keys(manifest.files).some((file) => file.startsWith(`skills/${skill}/scripts/`) && file.endsWith(".py")));
}
assert.match(await fs.readFile(artifact.bin, "utf8"), /LOCALAPP_PUBLIC_SKILLS_ROOT/);
assert.equal((await fs.readFile(artifact.bin, "utf8")).startsWith("#!/usr/bin/env node\n"), true);

const child = spawn(process.execPath, [artifact.bin, "start", "--data-dir", dataDirectory, "--port", "0"], {
Expand Down
21 changes: 21 additions & 0 deletions packages/server/skills/docx/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Nous Research

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.
Loading
Loading