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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| SDK | 语言 | SDK 包 | Demo | 说明 |
|---|---|---|---|---|
| Client SDK | TypeScript | [`@autoark-ai/eva-client-sdk-ts`](https://www.npmjs.com/package/@autoark-ai/eva-client-sdk-ts) | [`browser-conversation-agent`](client-sdk/ts/browser-conversation-agent/) | 浏览器端语音、多轮对话、TTS、麦克风控制,以及可选 Emotion、Command 与摄像头图片问答 |
| Client SDK | TypeScript | [`@autoark-ai/eva-client-sdk-ts`](https://www.npmjs.com/package/@autoark-ai/eva-client-sdk-ts) | [`electron-conversation-agent`](client-sdk/ts/electron-conversation-agent/) | Electron 桌面端语音、多轮对话、TTS、麦克风控制,以及可选 Emotion、Command 与摄像头图片问答 |

机器可读目录见 [`examples.json`](examples.json)。表格链接到每个 demo 使用的 SDK package;精确版本以各 demo 的 package manifest 和 lockfile 为事实源。目录中的 `status: "release"` 表示 demo 已正式对外发布,`status: "dev"` 表示仍在开发。`verify-catalog.mjs` 会校验表格、目录与 manifest 保持一致。

Expand Down
1 change: 1 addition & 0 deletions client-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
当前示例:

- TypeScript:[`ts/browser-conversation-agent`](ts/browser-conversation-agent/)
- TypeScript:[`ts/electron-conversation-agent`](ts/electron-conversation-agent/)
2 changes: 2 additions & 0 deletions client-sdk/ts/electron-conversation-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# 由 SDK 使用者(开发者)提供和管理;不要提交真实 AK。
VITE_EVA_API_KEY=replace-with-your-gateway-api-key
4 changes: 4 additions & 0 deletions client-sdk/ts/electron-conversation-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
dist/
dist-electron/
node_modules/
117 changes: 117 additions & 0 deletions client-sdk/ts/electron-conversation-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# EVA Client SDK · TypeScript Electron Conversation Agent

这是 EVA Client SDK 的 TypeScript Electron 桌面 demo,展示多轮语音对话、实时转写、TTS、麦克风控制、文本输入、emotion 旁路分类、command 调用,以及默认关闭的摄像头图片问答。

Electron 渲染进程运行在 Chromium 中,因此 demo 直接复用与浏览器 demo 相同的渲染层与 `@autoark-ai/eva-client-sdk-ts/browser` Media SPI helper。渲染进程只从 public npm package 的 `.`、`./spi` 和 `./browser` 三个公共入口导入,不依赖 SDK 源码、workspace link、internal seam 或本地 tarball。SDK 的创建与启动主路径集中在 `src/sdk-usage.ts`;`src/main.ts` 只负责页面交互、运行时 AK 输入、状态和事件展示。

Electron 主进程与预加载脚本放在独立的 `electron/` 目录:`electron/main.ts` 创建窗口、加载渲染进程并在 macOS 上申请媒体权限;`electron/preload.ts` 在启用 `contextIsolation` 的隔离世界里只暴露少量只读运行信息。主进程不参与任何 SDK 调用。

## 环境要求

- Node.js `>=22`
- macOS / Windows / Linux 桌面环境,并允许应用访问麦克风与摄像头
- 开发者自行管理的 EVA Gateway AK

## 项目结构

```text
electron-conversation-agent/
├── index.html # 渲染进程页面
├── src/ # 渲染进程:SDK 接入与页面交互(Chromium 中运行)
│ ├── main.ts # 页面层:DOM、AK 弹窗、事件展示
│ ├── sdk-usage.ts # SDK 创建/启动主路径
│ └── styles.css
├── electron/ # 主进程与预加载脚本(Node 中运行)
│ ├── main.ts
│ └── preload.ts
├── vite.config.ts # 渲染进程由 Vite 构建(base: "./")
├── tsconfig.json # 渲染进程 TS 配置
└── tsconfig.electron.json # 主进程 / 预加载 TS 配置(编译为 CommonJS)
```

## 本地运行

先安装依赖:

```bash
npm ci
```

> 依赖安装时,Electron 会为当前平台下载一个专属运行时二进制(约 90MB)。若处于受限网络或代理环境导致下载失败(`electron --version` 报 `Electron failed to install correctly`),可用官方的 `ELECTRON_MIRROR` 环境变量指向可访问的下载源后重装,详见 [Electron 安装文档](https://www.electronjs.org/docs/latest/tutorial/installation#mirror)。

`npm run dev` 会并行启动 Vite 开发服务器并在其就绪后拉起 Electron 窗口(主进程通过 `VITE_DEV_SERVER_URL` 加载开发服务器)。

### 页面输入 AK

```bash
npm run dev
```

窗口会在启动会话时要求输入 Gateway AK。手工输入的 AK 只保存在当前渲染进程内存中,关闭窗口后清除。

### 使用项目 .env

复制示例文件、填写 AK 后启动:

```bash
cp .env.example .env
npm run dev
```

### 使用外部 key 文件

key 文件可以位于仓库外,内容为:

```dotenv
EVA_GATEWAY_API_KEY=akxxx
```

从 demo 目录启动,并传入文件路径:

```bash
npm run dev:key-file -- /absolute/path/to/eva-key.env
```

`dev:key-file` 会读取所需变量并适配当前 demo,不会复制 key 文件、生成 `.env` 或输出 AK。

仓库不得提交真实 AK。SDK 不会把 AK 写入事件、错误或消息,但桌面应用的开发者仍须自行决定最终应用如何管理凭证。

## 媒体权限

渲染进程通过 `getUserMedia` 采集麦克风与摄像头。主进程为窗口注册了 `setPermissionRequestHandler`,只放行 `media` 权限请求;在 macOS 上还会在启动时调用 `systemPreferences.askForMediaAccess` 申请系统级麦克风与摄像头授权。首次运行时请在系统弹窗中允许,否则采集会失败。

## 可选能力:Emotion 与 Command

Emotion 和 Command 是彼此独立的 opt-in 能力,也都不是基础 Agent 的必选项。省略它们不会影响文本或语音会话、ASR、LLM、TTS、摄像头以及消息历史。

| 能力 | Demo 的启用方式 | 省略配置后的行为 | 运行时事件 |
|---|---|---|---|
| Emotion | `emotion: { enabled: true, labels: ["happy", "sad"] }` | 不发起旁路 emotion 分类 | 不产生 `emotion.detected` |
| Command | `commands: { registrations, maxCallsPerTurn: 3 }` | 不向 LLM 暴露 command definitions,也不执行 handler | 不产生 `command.called`、`command.completed` 或 `command.failed` |

Demo 注册了两个可实际触发的 command:

- “现在几点?”触发 `show_current_time`,返回设备本地时间。
- “把页面切换为深色主题”触发 `set_page_theme`,将页面切换为深色;也可以要求切回浅色。

这里的 custom emotion labels 会完整替换 SDK 默认业务标签,而不是在默认集合上追加;SDK 会在缺失时自动补充唯一的 `unknown`。`AgentEvent` 在类型层始终表示完整的公共事件目录,即使某个 Agent 实例没有启用 Emotion 或 Command,使用穷尽 `switch` 的消费者仍应保留这些事件分支。

## 生产构建

```bash
npm run build # typecheck → vite build(渲染进程)→ tsc(主进程/预加载)
npm start # 用 Electron 加载构建产物运行
```

`vite build` 使用相对资产路径输出到 `dist/`,主进程通过 `loadFile` 直接加载 `dist/index.html`;`tsc -p tsconfig.electron.json` 将主进程与预加载脚本编译到 `dist-electron/`。本 demo 只覆盖到本地运行与构建;如需分发安装包,可在此基础上引入 electron-builder 等打包工具。

## SDK 兼容性

精确 SDK 版本由 `package.json` 与 `package-lock.json` 共同锁定。升级 SDK 时必须重新运行:

```bash
npm install @autoark-ai/eva-client-sdk-ts@<version> --save-exact
npm run build
```

升级后的 demo 需要重新完成真实桌面窗口下的麦克风、TTS、摄像头和 Stop 释放回归后,才能创建新的已验证 release/tag。
67 changes: 67 additions & 0 deletions client-sdk/ts/electron-conversation-agent/electron/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { app, BrowserWindow, session, systemPreferences } from "electron";
import path from "node:path";

/**
* Electron 主进程。
*
* 只负责创建窗口、加载渲染进程(dev 用 Vite 开发服务器,生产加载 vite build 产物),
* 以及在 macOS 上向系统申请麦克风/摄像头权限并对渲染进程的媒体权限请求放行。
* EVA 会话本身完全运行在渲染进程里(见 src/sdk-usage.ts),主进程不参与 SDK 调用。
*/

// dev 模式由 npm run dev 注入;生产构建下为 undefined,改为加载本地 dist。
const devServerUrl = process.env.VITE_DEV_SERVER_URL;

function createWindow(): void {
const window = new BrowserWindow({
width: 960,
height: 860,
minWidth: 480,
minHeight: 640,
backgroundColor: "#0b0b10",
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
// 渲染进程需要 getUserMedia 采集麦克风/摄像头,沙箱下仍可用。
sandbox: true,
},
});

if (devServerUrl !== undefined) {
void window.loadURL(devServerUrl);
window.webContents.openDevTools({ mode: "detach" });
} else {
void window.loadFile(path.join(__dirname, "..", "dist", "index.html"));
}
}

async function requestMediaAccess(): Promise<void> {
// macOS 首次采集前需要显式申请系统权限,否则 getUserMedia 会静默失败。
if (process.platform !== "darwin") return;
try {
await systemPreferences.askForMediaAccess("microphone");
await systemPreferences.askForMediaAccess("camera");
} catch {
// 用户拒绝时继续启动;渲染进程会在 error 事件里反映不可用状态。
}
}

void app.whenReady().then(async () => {
// 放行渲染进程发起的 media(麦克风/摄像头)权限请求,其余一律拒绝。
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(permission === "media");
});

await requestMediaAccess();
createWindow();

app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
17 changes: 17 additions & 0 deletions client-sdk/ts/electron-conversation-agent/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { contextBridge } from "electron";

/**
* 预加载脚本,在启用 contextIsolation 的隔离世界里运行。
*
* 本 demo 的渲染进程直接通过公开 SDK 与 EVA 通信,不需要主进程特权能力,
* 因此这里只暴露少量只读的运行环境信息。如果后续渲染进程需要访问主进程能力,
* 在这里通过 contextBridge 显式、最小化地暴露,而不是打开 nodeIntegration。
*/
contextBridge.exposeInMainWorld("evaElectron", {
platform: process.platform,
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
},
});
163 changes: 163 additions & 0 deletions client-sdk/ts/electron-conversation-agent/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>EVA Electron Conversation Agent</title>
</head>
<body>
<main>
<header class="app-header">
<div class="brand">
<span class="brand-mark" aria-hidden="true">E</span>
<div>
<p class="eyebrow">EVA Voice</p>
<h1>语音对话</h1>
</div>
</div>
<p class="sdk-label">@autoark-ai/eva-client-sdk-ts</p>
</header>

<section id="voice-stage" class="voice-stage" data-state="idle" aria-labelledby="voice-state">
<div class="voice-copy">
<p class="stage-kicker">
<span class="status-dot" aria-hidden="true"></span>
<span id="status" role="status">未启动</span>
<span id="call-duration" class="call-duration" hidden>00:00</span>
</p>
<h2 id="voice-state">准备好聊聊了吗?</h2>
<p id="voice-hint">启动后直接开口,EVA 会实时听见并回应你。</p>
</div>

<div class="voice-visual" aria-hidden="true">
<div class="waveform">
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>

<div class="session-actions">
<button id="start" class="primary-action" type="button">
<span class="mic-icon" aria-hidden="true"></span>
开始语音对话
</button>
<button id="stop" class="stop-action" type="button" disabled hidden>
<span class="stop-icon" aria-hidden="true"></span>
结束通话
</button>
</div>
</section>

<section class="device-bar" aria-label="对话设备">
<div>
<p class="device-title">对话设备</p>
<p class="device-description">会话中也可以随时调整</p>
</div>
<div class="device-controls">
<button id="audio-input" class="switch-control" type="button" role="switch" aria-checked="true">
<span class="control-icon mic-icon" aria-hidden="true"></span>
<span>麦克风</span>
<span class="switch-indicator" aria-hidden="true"></span>
</button>
<button id="tts" class="switch-control" type="button" role="switch" aria-checked="true">
<span class="control-icon speaker-icon" aria-hidden="true"></span>
<span>语音回复</span>
<span class="switch-indicator" aria-hidden="true"></span>
</button>
<button id="camera" class="switch-control" type="button" role="switch" aria-checked="false">
<span class="control-icon camera-icon" aria-hidden="true"></span>
<span>摄像头</span>
<span class="switch-indicator" aria-hidden="true"></span>
</button>
</div>
</section>

<section class="conversation" aria-labelledby="conversation-title">
<div class="section-heading conversation-heading">
<div>
<p class="section-kicker">Conversation</p>
<h2 id="conversation-title">对话记录</h2>
</div>
<span class="privacy-note">仅保留在当前会话</span>
</div>
<ol id="messages" class="messages"></ol>
<div id="conversation-empty" class="conversation-empty">
<span class="empty-wave" aria-hidden="true"></span>
<p>对话开始后,双方内容会显示在这里</p>
</div>

<div class="live-turn" aria-live="polite">
<article>
<p class="live-label"><span class="live-dot user-dot"></span>你正在说</p>
<p id="transcript">等待语音输入</p>
</article>
<article>
<p class="live-label"><span class="live-dot eva-dot"></span>EVA 正在回应</p>
<p id="reply">等待开始对话</p>
</article>
</div>

<form id="text-form" class="text-composer">
<label class="visually-hidden" for="text">输入文字消息</label>
<input id="text" autocomplete="off" placeholder="也可以输入文字消息…" />
<button type="submit" aria-label="发送文字消息" disabled>
<span class="send-icon" aria-hidden="true"></span>
</button>
</form>
</section>

<details class="diagnostics">
<summary>
<span>开发者诊断</span>
<span class="summary-hint">事件、设备与 latency</span>
</summary>
<div class="diagnostic-content">
<section class="diagnostic-panel" aria-labelledby="activity-log-title">
<div class="section-heading">
<h2 id="activity-log-title">状态日志</h2>
<button id="clear-log" class="secondary-button" type="button">清空</button>
</div>
<textarea id="activity-log" rows="12" readonly aria-label="页面状态变化日志"></textarea>
</section>
<section class="diagnostic-panel">
<h2>运行状态</h2>
<dl>
<div><dt>麦克风</dt><dd id="microphone">未请求</dd></div>
<div><dt>摄像头</dt><dd id="camera-status">默认关闭</dd></div>
<div><dt>打断</dt><dd id="interruption">—</dd></div>
<div><dt>情绪</dt><dd id="emotion">—</dd></div>
<div><dt>Command</dt><dd id="command">—</dd></div>
<div><dt>错误</dt><dd id="error">—</dd></div>
</dl>
<pre id="latency">暂无 latency</pre>
</section>
</div>
</details>
</main>

<dialog id="api-key-dialog" aria-labelledby="api-key-title">
<form id="api-key-form" class="api-key-form" method="dialog">
<h2 id="api-key-title">输入 Gateway AK</h2>
<p>AK 只保存在当前页面内存中,刷新页面后会清除。</p>
<label for="api-key-input">Gateway AK</label>
<input
id="api-key-input"
type="password"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
/>
<p id="api-key-error" class="field-error" role="alert"></p>
<div class="dialog-actions">
<button id="api-key-cancel" class="secondary-button" type="button">取消</button>
<button type="submit">使用此 AK 启动</button>
</div>
</form>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Loading
Loading