Jarvis AI Platform 实战教程,展示后端 RAG 到前端实时 UI 的完整 AI 应用构建流程。对快速上手 AI 应用开发有参考价值。
从终端到浏览器——Jarvis AI Platform 的第 7 阶段。
在完成六个以后端为核心的阶段后,Jarvis 已经演进为一个功能完备的 local-first AI 平台。
Phase 1 → AI Chat + JWT + CLI
Phase 2 → Long-Term Memory + pgvector
Phase 3 → RAG Document Search
Phase 4 → Tool Engine + MCP Server
Phase 5 → Voice Assistant (Whisper + TTS)
Phase 6 → ReACT Agent System
但所有功能都只能在终端中使用。
第 7 阶段将改变这一点。
目标是在不违背项目理念的前提下,打造一个现代化的浏览器界面:
你的 AI。你的数据。你的机器。
在编写第一个组件之前,我们先在社区发起了投票。
选择 Angular 有三个原因。
Jarvis 基于 Spring WebFlux 构建。
后端已经通过响应式流和 Server-Sent Events(SSE)进行通信。
Angular 通过 RxJS 原生拥抱这种模型,因此实现流式 API 非常自然。
与手动协调 React hooks 相比,管理订阅、取消、清理和流组合要简洁得多。
开源项目的推进速度取决于维护者。
使用一个我已经深入掌握的框架意味着:
更容易为贡献者提供支持。
贡献者随时都可以学习 Angular。
但维护者不应该在审查每一个 pull request 的同时,还要学习一套全新的生态系统。
Angular Material 提供了非常出色的基础:
但整体外观是定制的。
所有布局、间距规则、字体排印选择和配色方案,都是通过 SCSS 实现的。
最终呈现出来的是 Jarvis 的感觉,而不是又一个千篇一律的 Material 应用。
对于 Server-Sent Events,最显而易见的选择是 EventSource。
// ❌ Doesn't work
const source = new EventSource('/api/v1/chat/stream');
遗憾的是,它有三个主要限制:
无法发送 request body
无法发送 Authorization headers
而 Jarvis 三者都需要。
每个聊天请求都需要:
使用 JWT 进行身份认证
因此,EventSource 根本不是一个可行的选项。
因此,每一个流式 endpoint 都改用 fetch()。
fetch('/api/v1/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream'
},
body: JSON.stringify(request)
})
.then(response => response.body!.getReader())
.then(reader => readStream(reader));
这种方式支持:
完全相同的实现还驱动着:
只需一套流式实现。
后端会发送这样的事件:
event: token
data: {"t":"Hello"}
event: token
data: {"t":" world"}
event: done
data: [DONE]
前端正确处理了 done 事件。
但随后发生了一件奇怪的事。
当浏览器读取完流之后,reader.read() 还会返回:
done === true
于是 onDone() 被执行了两次。
每一条 AI 回复的末尾,都会多出一条空白的 assistant 消息。
只需要一个 guard 就能解决这个问题。
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
onDone();
};
现在,两条代码路径都会调用 finish()。
无论流以何种方式结束,onDone() 都只会执行一次。
Dark mode 并不是后来才补上的功能。
它从一开始就在设计之中。
每一种颜色都是 CSS custom property。
:root {
--bg-primary: #0f1117;
--bg-secondary: #1a1d27;
--accent-primary: #6366f1;
--success: #10b981;
--error: #ef4444;
--error-surface: rgba(239,68,68,.12);
}
Light mode 只需覆盖这些变量。
.light-theme {
--bg-primary: white;
--bg-secondary: #f8f9fc;
}
组件中绝不硬编码颜色。
background: var(--bg-primary);
color: var(--text-primary);
切换主题时,无需修改任何组件,就能重新绘制整个应用。
每个页面都使用 Signals 管理 UI 状态。
readonly messages = signal<Message[]>([]);
readonly streamingContent = signal('');
readonly isStreaming = signal(false);
计算状态也可以保持简单。
readonly canSend = computed(() =>
this.input().trim().length > 0 &&
!this.isStreaming()
);
Signals
↓
Everything the template displays
Observables
↓
HTTP calls
SSE streams
Background work
这样的职责划分让组件小得出乎意料。
在流式传输期间,Send 按钮会变成 Stop 按钮。
最初的实现复用了同一个按钮。
<button [disabled]="!canSend()">
流式传输期间,canSend() 会变成 false。
所以 Stop 按钮虽然出现了……
但实际上什么也停止不了。
解决方案是把它拆成两个按钮。
@if (isStreaming()) {
<button (click)="stopStreaming()">
stop_circle
</button>
} @else {
<button
[disabled]="!canSend()"
(click)="sendMessage()">
send
</button>
}
停止操作只需中止 fetch 请求。
private abortController: AbortController | null = null;
stopStreaming() {
this.abortController?.abort();
}
添加 CorsWebFilter 看起来是正确的做法。
但 Spring Security 会在该 filter 执行之前拦截 OPTIONS 请求。
真正的解决方案位于 ServerHttpSecurity 中。
@Bean
SecurityWebFilterChain security(ServerHttpSecurity http) {
return http
.cors(cors -> cors.configurationSource(
corsConfigurationSource()))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.build();
}
如果没有这项配置,浏览器会拒绝每一个经过身份认证的流式请求,甚至不会让它到达 controller。
✅ Login
Register/Login tabs
JWT authentication
✅ App Shell
Sidebar
Topbar
Lazy routing
✅ Chat
Real-time SSE
Markdown rendering
Stop streaming
✅ Settings
Provider health
Voice configuration
✅ Memory
CRUD
Type badges
Confirmation dialogs
📋 Documents
📋 Agents
📋 Voice
这些都是当前开放给贡献者的 issue。
CodeRabbit 在用户遇到问题之前,发现了多个可访问性缺陷。
不再使用普通的 div,而是:
<div role="status"
aria-live="polite">
<div role="alert"
aria-live="assertive">
现在,screen reader 会自动播报消息。
仅仅调整 opacity 是不够的。
键盘用户仍然可能通过 Tab 键聚焦到一个不可见的按钮上。
.memory-card:focus-within .memory-card__delete {
opacity: 1;
}
.memory-card__delete:focus-visible {
outline: 2px solid var(--accent-primary);
}
现在,键盘导航的行为已经正确了。
dialog 也获得了正确的语义。
<div
role="dialog"
aria-modal="true"
aria-labelledby="clear-confirm-title">
可访问性得到了大幅改善。
如果你的 API 有以下要求:
从一开始就使用 fetch() 和 ReadableStream。
硬编码颜色最终一定会破坏 dark mode。
所有可复用的颜色都被定义成了 design token。
随着新页面不断加入,这个决定仍在持续带来收益。
手动执行 change detection
模板中的订阅
模板变得更容易阅读。
自动化审查发现了多个足以影响生产质量的 Bug:
onDone() 执行两次
无法访问的删除按钮
错误的 CORS 配置
这些 Bug 最终都没有触达用户。
每项功能都可以独立加载。
{
path: 'memory',
loadComponent: () =>
import('./features/memory/memory')
.then(m => m.MemoryPage)
}
贡献者可以添加全新的页面,而不会增大初始 bundle 的体积。
目前还剩下三个主要页面:
📄 Documents
File upload
Status polling
🤖 Agents
Live ReACT execution
Step-by-step streaming
🎙 Voice
MediaRecorder
SSE AI responses
之后就是打磨工作:
后端已经完成了六个阶段。
第 7 阶段的目标,是通过精心打磨的浏览器体验,让用户能够使用这些功能。
Jarvis AI Platform 采用 Apache 2.0 License 开源。
当前开放给贡献者的 issue:
Documents Page → Good First Issue
Agents Page → Intermediate
Voice Page → Advanced
https://github.com/sujankim/jarvis-ai-platform
第 1 部分——使用 Spring Boot 4 构建 Local-First AI Assistant
第 2 部分——使用 pgvector 实现 Long-Term Memory
第 3 部分——Semantic Memory Retrieval
第 4 部分——使用 Spring AI 构建 Tool Engine
第 5 部分——使用 Whisper 与 Text-to-Speech 构建 Voice Assistant
第 6 部分——使用 ReACT Pattern 构建 AI Agent System
第 7 部分——使用 Angular 22 构建实时 Web UI(本文)
你的 AI。你的数据。你的机器。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。