Skip to content
Open
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 .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Optional overrides. Leave empty to use the production defaults in server/src/index.ts.
# Voice Realtime derives its CN/Global WebSocket target from the matching Forward base URL.
CN_PROD_FORWARD_API_BASE_URL=https://api.qoder.com.cn/api/v1/forward
GLOBAL_PROD_FORWARD_API_BASE_URL=https://api.qoder.com/api/v1/forward
CN_PROD_CLOUD_API_BASE_URL=https://api.qoder.com.cn/api/v1/cloud
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ worktrees/
server/logs/
.vercel
.env*
.superpowers/
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Forward Quickstart 是一个用于体验 Qoder Cloud Agents Forward API 的示
- **密钥管理**:在 Vault 中创建和删除密钥,支持 Bearer Token、OAuth Token 和环境变量类型。
- **会话执行**:基于 Identity 和 Template 创建 Session,发送 `user.message`,并支持取消当前 Turn;遇到需要确认的工具调用或 `AskUserQuestion` 时,由用户显式允许、拒绝、作答或跳过后继续执行。已有任务执行时仍可点击「新建对话」另起新会话,后台任务继续执行且不会干扰当前视图。提问时可点击发送按钮旁的回形针图标添加本地文本类文件(单个 ≤5MB)作为对话附件——文件上传后挂载到 Agent 工作目录(新会话随创建挂载,进行中的会话动态追加挂载),消息中自动标注挂载路径,Agent 可直接读取附件内容作答;附件在消息气泡中以文件卡片展示,刷新后依然可见。
- **实时事件**:通过 SSE 接收 Agent 状态、消息、思考过程、工具调用和工具结果,支持打字机流式输出并实时渲染 Markdown;Agent 通过 DeliverArtifacts 交付的图片文件在对话中内联预览,支持点击放大和下载原图;流式过程中不完整的表格/标题片段也能安全渲染,Session 运行失败(如模型过载)会在对话中显示错误提示;消息按服务端时间戳排序展示,多轮追问时新提问始终显示在对话最底部。多 Agent 协作场景下,子线程的创建、委派、运行和完成等状态事件以轻量标签形式在对话中展示,子线程完成不会提前终止 SSE 流,确保协调者能正确接收子线程结果;单 Agent 会话中自动过滤从其他会话 SSE 流泄漏的多 Agent 状态事件(如「子线程已完成」),避免显示无关标签;协调者(Coordinator)输出的重复回复(如先输出草稿再输出验收版)会通过字符二元组 Jaccard 相似度自动去重,仅保留最终版本。发送按钮旁的设置图标可开关「显示思考过程 / 显示工具调用过程」(选择持久化到本地);点击历史会话加载事件时显示加载动画,不会闪现欢迎页;历史会话列表支持置顶——悬浮某条记录时显示图钉图标,点击后该会话固定到列表顶部的「置顶」分组(再次点击取消置顶,置顶状态持久化到本地)。
- **实时语音(本地)**:Template 开启 Realtime 后,可从聊天输入框单击麦克风直接启动独立 Voice Session;页面展示实时字幕、Agent 音频、Work 任务进度、文字混输、静音与结束控制。语音 Session 在历史列表带有“语音”标签,并通过后端 metadata 中的 `conversation_id` 恢复时间线。CN 与 Global 使用相同交互;WebSocket URL 只携带短时单次 connection key,本地 Node 代理负责携带用户在页面中输入的 PAT 连接上游 WebSocket。
- **模板快速切换**:在对话列表顶部直接切换当前会话使用的 Template,无需离开对话界面。
- **权限模式**:内置「开发者模式 / 用户模式」开关(默认用户模式,选择持久化到本地)。开发者模式解锁模板及模板资源(技能、文件、环境、密钥)的新建、编辑和删除权限,并显示对应的「模板资源」菜单;用户模式仅能查看和使用模板。切换到开发者模式时会弹出风险确认提示。
- **会话历史与用量**:查看历史 Session、事件历史、执行状态和会话时长统计。
Expand Down Expand Up @@ -113,6 +114,9 @@ npm run dev

启动成功后,终端会同时显示前端 Vite 服务和 Express 本地代理的日志。打开前端地址后,在登录页面选择 API 环境并输入对应的 Forward PAT 即可开始体验。

Voice 仅在本地开发模式可用。首次启动会请求麦克风权限;若麦克风不可用,仍可在 Voice 页面用文字继续对话。Voice 按钮不可用时,请确认当前 Template 已开启 Realtime。
Voice 中继只接受来自 `localhost`、`127.0.0.1` 或 `::1` 页面的一次性连接,并仅用于本机开发;请勿将本地代理端口 `3001` 暴露到公网。

如果端口被占用,可以先停止占用 `5173` 或 `3001` 的本地进程,再重新执行 `npm run dev`。

## Vercel 部署
Expand All @@ -121,6 +125,7 @@ npm run dev

- 本地运行继续使用 `npm run dev`,Vite 会将 `/api` 请求代理到本地 Express 服务。
- Vercel 会构建 `client/dist` 并将现有 Express API 作为 Serverless Functions 部署;前端和 API 使用同一域名。
- 当前 Vercel 在线 Demo 不提供 Voice WebSocket 中继;普通 HTTP、SSE 和其他现有能力不受影响。

建议先 Fork 本仓库到自己的 GitHub 账号,再在 Vercel 导入 Fork 后的仓库。Vercel 检测到根目录的 `vercel.json` 后会自动使用正确的构建命令和 API 函数配置。

Expand Down
61 changes: 53 additions & 8 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ import { ChatImage } from './chatImage';
import { isImageFile } from './imageUtils';
import { BatchPanel } from './batchPanel';
import { UsagePanel } from './usagePanel';
import { VoiceEntryButton } from './voice/VoiceEntryButton';
import { VoiceSessionView } from './voice/VoiceSessionView';
import { useVoiceAvailability } from './voice/useVoiceAvailability';
import { isVoiceSession } from './voice/voiceSession';
import { LayerQuizButton } from './layerQuiz';
import { PRODUCT_NAME } from './config/product';
import {
Expand Down Expand Up @@ -2135,6 +2139,9 @@ export default function App() {
const [resourceOptionsByType, setResourceOptionsByType] = useState<Record<ForwardResourceType, ForwardResource[]>>(emptyResourceOptions);
const [sessions, setSessions] = useState<ForwardSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState('');
const [voiceViewOpen, setVoiceViewOpen] = useState(false);
const [currentVoiceConversationId, setCurrentVoiceConversationId] = useState<string | null>(null);
const [voiceLaunchKey, setVoiceLaunchKey] = useState(0);
const [events, setEvents] = useState<ForwardEvent[]>([]);
const [input, setInput] = useState('');
const [showThinking, setShowThinking] = useState<boolean>(() => {
Expand Down Expand Up @@ -2325,6 +2332,8 @@ export default function App() {
() => (pat.trim() ? { pat: pat.trim(), environment: apiEnvironment } : null),
[apiEnvironment, pat],
);
const voiceAvailability = useVoiceAvailability(ctx, templateId);
useEffect(() => { setVoiceViewOpen(false); setCurrentVoiceConversationId(null); }, [templateId]);

// Attachments picked in the composer. They upload immediately on selection
// (ChatGPT-style progress chips), then get mounted into the session
Expand Down Expand Up @@ -2495,12 +2504,13 @@ export default function App() {
}, [currentSessionId]);

const refreshSessions = useCallback(async (nextIdentity = identity, nextTemplateId?: string) => {
if (!ctx || !nextIdentity) return;
if (!ctx || !nextIdentity) return [];
const tplId = nextTemplateId ?? templateIdRef.current;
const page = await listSessions(ctx, nextIdentity.id, tplId || undefined);
setSessions(page.data);
// Never auto-select a session here: background polling calls this while the
// user may be sitting on the new-conversation screen (currentSessionId === '').
return page.data;
}, [ctx, identity]);

const loadSessionEvents = useCallback(async (sessionId: string) => {
Expand Down Expand Up @@ -3449,6 +3459,16 @@ export default function App() {
setError('');
setSessionLoading(true);
streamAbort.current?.abort();
const selected = sessions.find((session) => session.id === sessionId);
if (selected && isVoiceSession(selected)) {
setCurrentVoiceConversationId(selected.metadata.conversation_id);
setVoiceViewOpen(true);
setVoiceLaunchKey((value) => value + 1);
setSessionLoading(false);
return;
}
setVoiceViewOpen(false);
setCurrentVoiceConversationId(null);
try {
const page = await listEvents(ctx!, sessionId);
// Events are returned in descending order (newest first), reverse to chronological order
Expand Down Expand Up @@ -4975,7 +4995,7 @@ export default function App() {
)}
</div>
<button
onClick={() => { currentSessionIdRef.current = ''; setCurrentSessionId(''); setEvents([]); setSessionLoading(false); }}
onClick={() => { currentSessionIdRef.current = ''; setCurrentSessionId(''); setEvents([]); setSessionLoading(false); setVoiceViewOpen(false); setCurrentVoiceConversationId(null); }}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-black/45 transition hover:bg-gray-100 hover:text-black"
title="新建对话"
>
Expand Down Expand Up @@ -5061,8 +5081,9 @@ export default function App() {
</span>
)}
<div className="min-w-0 flex-1">
<div className={`truncate text-[13px] ${currentSessionId === session.id ? 'font-medium text-black' : 'text-black/70'}`}>
{session.title || 'Forward 会话'}
<div className={`flex items-center gap-1.5 text-[13px] ${currentSessionId === session.id ? 'font-medium text-black' : 'text-black/70'}`}>
<span className="truncate">{session.title || 'Forward 会话'}</span>
{isVoiceSession(session) && <span className="shrink-0 rounded-full bg-[#3550FF]/8 px-1.5 py-0.5 text-[10px] font-normal text-[#3550FF]">语音</span>}
</div>
<div className="mt-0.5 text-[11px] text-black/30">{relativeTime(session.created_at)}</div>
</div>
Expand Down Expand Up @@ -5104,15 +5125,37 @@ export default function App() {
e.target.value = '';
}}
/>
{events.length === 0 && sessionLoading && (
{voiceViewOpen && ctx && identity && templateId && (
<VoiceSessionView
key={voiceLaunchKey}
ctx={ctx}
identityId={identity.id}
templateId={templateId}
templateName={currentTemplate?.name || 'Voice'}
initialConversationId={currentVoiceConversationId}
autoStart={currentVoiceConversationId === null}
launchKey={voiceLaunchKey}
onConversationCreated={(conversationId) => {
setCurrentVoiceConversationId(conversationId);
void refreshSessions().then((items) => {
const matched = items.find((item) => isVoiceSession(item) && item.metadata.conversation_id === conversationId);
if (matched) { currentSessionIdRef.current = matched.id; setCurrentSessionId(matched.id); }
});
}}
onStartFailed={(message) => { setVoiceViewOpen(false); setCurrentVoiceConversationId(null); setError(message); }}
onNewConversation={() => { setCurrentVoiceConversationId(null); setVoiceLaunchKey((value) => value + 1); }}
onEnded={() => { void refreshSessions(); }}
/>
)}
{!voiceViewOpen && events.length === 0 && sessionLoading && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<svg className="h-7 w-7 animate-spin text-[#3550FF]" fill="none" viewBox="0 0 24 24">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-90" fill="currentColor" d="M12 2a10 10 0 0 1 10 10h-3a7 7 0 0 0-7-7V2Z" />
</svg>
</div>
)}
{events.length === 0 && !sessionLoading && (
{!voiceViewOpen && events.length === 0 && !sessionLoading && (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 pb-12 pt-4">
<div className="w-full max-w-[680px]">
<div className="mb-8 text-center">
Expand Down Expand Up @@ -5153,6 +5196,7 @@ export default function App() {
<span>换行</span>
</div>
<div className="flex items-center gap-1.5">
<VoiceEntryButton availability={voiceAvailability} onStart={() => { streamAbort.current?.abort(); setEvents([]); setError(''); setCurrentVoiceConversationId(null); setVoiceViewOpen(true); setVoiceLaunchKey((value) => value + 1); }} />
<button
type="button"
title="添加附件(图片 ≤10MB,文本类文件 ≤5MB)"
Expand Down Expand Up @@ -5207,7 +5251,7 @@ export default function App() {
</div>
</div>
)}
{events.length > 0 && (
{!voiceViewOpen && events.length > 0 && (
<div ref={chatScrollRef} onScroll={handleChatScroll} className="min-h-0 flex-1 overflow-y-auto px-8">
<div className="mx-auto flex max-w-[860px] flex-col py-6">
<div className="space-y-4 pb-8">
Expand Down Expand Up @@ -5277,7 +5321,7 @@ export default function App() {
</div>
</div>
)}
{events.length > 0 && (
{!voiceViewOpen && events.length > 0 && (
<div className="shrink-0 px-8 pb-6 pt-2">
<div className="mx-auto max-w-[860px]">
{(pendingToolApprovals.length > 0 || pendingQuestion) && (
Expand Down Expand Up @@ -5326,6 +5370,7 @@ export default function App() {
<span>换行</span>
</div>
<div className="flex items-center gap-1.5">
<VoiceEntryButton availability={voiceAvailability} onStart={() => { streamAbort.current?.abort(); setEvents([]); setError(''); setCurrentVoiceConversationId(null); setVoiceViewOpen(true); setVoiceLaunchKey((value) => value + 1); }} />
<button
type="button"
title="添加附件(图片 ≤10MB,文本类文件 ≤5MB)"
Expand Down
10 changes: 8 additions & 2 deletions client/src/forwardApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface ForwardSession {
// only sporadically present and drifts, so treat it as a fallback. Both may be
// absent when the billing module is off. See credits.ts for the details.
usage?: { credits?: number | null; total_credits?: number | null };
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
archived_at?: string | null;
Expand Down Expand Up @@ -157,12 +158,17 @@ export class ForwardApiError extends Error {
}
}

async function forwardRequest<T>(
export interface ForwardRequestOptions {
idempotencyKey?: string;
}

export async function forwardRequest<T>(
ctx: ForwardContext,
method: string,
path: string,
body?: unknown,
query?: Record<string, unknown>,
options: ForwardRequestOptions = {},
): Promise<T> {
const res = await fetch('/api/forward/request', {
method: 'POST',
Expand All @@ -175,7 +181,7 @@ async function forwardRequest<T>(
body,
query,
idempotencyKey: method.toUpperCase() === 'POST'
? `fw-${Date.now()}-${Math.random().toString(36).slice(2)}`
? options.idempotencyKey || `fw-${Date.now()}-${Math.random().toString(36).slice(2)}`
: undefined,
}),
});
Expand Down
4 changes: 4 additions & 0 deletions client/src/voice/VoiceEntryButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { VoiceAvailability } from './useVoiceAvailability';
export function VoiceEntryButton({ availability, onStart }: { availability: VoiceAvailability; onStart: () => void }) {
return <button type="button" aria-label="开始语音对话" title={availability.enabled ? '开始语音对话' : availability.reason} disabled={!availability.enabled} onClick={onStart} className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-black/40 transition hover:bg-[#3550FF]/8 hover:text-[#3550FF] disabled:cursor-not-allowed disabled:opacity-30"><svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3M9 21h6"/></svg></button>;
}
Loading