【渠道端到端·接线(agent-72)】 - 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages; 入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。 - cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。 - wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。 - 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动; webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。 【RAG 知识库(agent-71)】 - internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。 ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节 命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。 - gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。 - admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。 - projectindex/store.go:ListProjects/ListChapters。 - 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice), 上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。 【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。 实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。 go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。 遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler 需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
272 lines
8.8 KiB
TypeScript
272 lines
8.8 KiB
TypeScript
import type {
|
||
AdminAutomationRecord,
|
||
AdminChannelRecord,
|
||
AdminIoTDevice,
|
||
AdminLlmConfig,
|
||
AdminPluginRecord,
|
||
AdminRagHit,
|
||
AdminRagResponse,
|
||
AdminUserRecord,
|
||
ApiErrorShape,
|
||
ChatSyncResult,
|
||
LoginResult,
|
||
MemoryRecord,
|
||
Mode,
|
||
PersonaRecord,
|
||
Role,
|
||
SessionRecord,
|
||
} from "./types";
|
||
|
||
/**
|
||
* REST client for the Cyrene v2 gateway. The gateway wraps every success in
|
||
* `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md).
|
||
* We use same-origin relative paths so the same code works behind the Vite dev
|
||
* proxy and inside the single-binary gateway on :8080.
|
||
*/
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code?: string;
|
||
constructor(status: number, code: string | undefined, message: string) {
|
||
super(message);
|
||
this.name = "ApiError";
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
const API_BASE = "/api/v1";
|
||
const TOKEN_KEY = "cyrene.jwt";
|
||
const ROLE_KEY = "cyrene.role";
|
||
const API_KEY_KEY = "cyrene.apikey";
|
||
|
||
export function getAuthToken(): string | null {
|
||
return localStorage.getItem(TOKEN_KEY);
|
||
}
|
||
export function setAuthToken(token: string | null): void {
|
||
if (token) {
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
setStoredRole(decodeJwtRole(token));
|
||
} else {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
setStoredRole(null);
|
||
}
|
||
}
|
||
|
||
/** Decode the RBAC role from a JWT payload (client-side only; never verified).
|
||
* base64url is converted to base64 before decoding to survive `-`/`_`. */
|
||
export function decodeJwtRole(token: string): Role | null {
|
||
const parts = token.split(".");
|
||
if (parts.length < 2) return null;
|
||
try {
|
||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
||
const payload = JSON.parse(atob(padded)) as Record<string, unknown>;
|
||
const r = payload.role;
|
||
return r === "owner" || r === "admin" || r === "viewer" ? r : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Persisted RBAC role, refreshed when the token changes. Falls back to
|
||
* decoding the token so a session created before role persistence still works. */
|
||
export function getStoredRole(): Role | null {
|
||
const r = localStorage.getItem(ROLE_KEY);
|
||
if (r === "owner" || r === "admin" || r === "viewer") return r;
|
||
const token = getAuthToken();
|
||
return token ? decodeJwtRole(token) : null;
|
||
}
|
||
export function setStoredRole(role: Role | null): void {
|
||
if (role) localStorage.setItem(ROLE_KEY, role);
|
||
else localStorage.removeItem(ROLE_KEY);
|
||
}
|
||
|
||
/** Optional API key for admin endpoints (X-API-Key). */
|
||
export function getApiKey(): string | null {
|
||
return localStorage.getItem(API_KEY_KEY);
|
||
}
|
||
export function setApiKey(key: string | null): void {
|
||
if (key) localStorage.setItem(API_KEY_KEY, key);
|
||
else localStorage.removeItem(API_KEY_KEY);
|
||
}
|
||
|
||
interface Envelope {
|
||
data?: unknown;
|
||
error?: ApiErrorShape;
|
||
}
|
||
|
||
async function request<T>(
|
||
path: string,
|
||
init: RequestInit = {}
|
||
): Promise<T> {
|
||
const headers = new Headers(init.headers);
|
||
headers.set("Content-Type", "application/json");
|
||
headers.set("X-API-Version", "1");
|
||
const token = getAuthToken();
|
||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||
const apiKey = getApiKey();
|
||
if (apiKey) headers.set("X-API-Key", apiKey);
|
||
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : "网络请求失败";
|
||
throw new ApiError(0, "network_error", msg);
|
||
}
|
||
|
||
let body: Envelope;
|
||
try {
|
||
body = (await res.json()) as Envelope;
|
||
} catch {
|
||
throw new ApiError(res.status, "bad_response", "服务端返回了无法解析的响应");
|
||
}
|
||
|
||
if (!res.ok || body.error) {
|
||
throw new ApiError(
|
||
res.status,
|
||
body.error?.code ?? "unknown",
|
||
body.error?.message ?? `请求失败(${res.status})`
|
||
);
|
||
}
|
||
return body.data as T;
|
||
}
|
||
|
||
/** POST /api/v1/auth/login — exchanges user/password for a JWT. */
|
||
export function login(username: string, password: string): Promise<LoginResult> {
|
||
return request<LoginResult>("/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
}
|
||
|
||
/** GET /api/v1/health — liveness probe. */
|
||
export function health(): Promise<{ status: string }> {
|
||
return request<{ status: string }>("/health");
|
||
}
|
||
|
||
/** POST /api/v1/chat — one synchronous agent turn. */
|
||
export function chatSync(
|
||
sessionId: string,
|
||
text: string
|
||
): Promise<ChatSyncResult> {
|
||
return request<ChatSyncResult>("/chat", {
|
||
method: "POST",
|
||
body: JSON.stringify({ sessionId, text }),
|
||
});
|
||
}
|
||
|
||
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel.
|
||
* `image` (a base64 data: URI or URL) is attached for the vision path; the
|
||
* gateway's chat body currently has no image field, so an unconfigured backend
|
||
* simply ignores it. */
|
||
export function chatStream(
|
||
sessionId: string,
|
||
text: string,
|
||
image?: string
|
||
): Promise<{ stream: true }> {
|
||
return request<{ stream: true }>("/chat", {
|
||
method: "POST",
|
||
body: JSON.stringify({ sessionId, text, image, stream: true }),
|
||
});
|
||
}
|
||
|
||
/** GET /api/v1/sessions — known channel sessions. */
|
||
export function fetchSessions(): Promise<SessionRecord[]> {
|
||
return request<SessionRecord[]>("/sessions");
|
||
}
|
||
|
||
/** GET /api/v1/personas — persona summaries. */
|
||
export function fetchPersonas(): Promise<PersonaRecord[]> {
|
||
return request<PersonaRecord[]>("/personas");
|
||
}
|
||
|
||
/** Is this error a "backend has not implemented it yet" case (404/501/403 on a
|
||
* not-yet-wired route) rather than a real connectivity problem? */
|
||
export function isNotImplemented(err: unknown): boolean {
|
||
if (err instanceof ApiError) {
|
||
return err.status === 404 || err.status === 501 || err.code === "not_found";
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
* Admin API (/api/v1/admin/*). Reference: Docs/api-client.md §2 + design/10.
|
||
* Implemented endpoints return real data; the rest are contract-shaped and are
|
||
* expected to 404 until the backend wires them — callers show a graceful shell.
|
||
* -------------------------------------------------------------------------- */
|
||
|
||
/** GET /api/v1/admin/personas */
|
||
export function adminListPersonas(): Promise<PersonaRecord[]> {
|
||
return request<PersonaRecord[]>("/admin/personas");
|
||
}
|
||
/** GET /api/v1/admin/sessions */
|
||
export function adminListSessions(): Promise<SessionRecord[]> {
|
||
return request<SessionRecord[]>("/admin/sessions");
|
||
}
|
||
/** GET /api/v1/admin/memories */
|
||
export function adminListMemories(): Promise<MemoryRecord[]> {
|
||
return request<MemoryRecord[]>("/admin/memories");
|
||
}
|
||
/** GET /api/v1/admin/plugins */
|
||
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
|
||
return request<AdminPluginRecord[]>("/admin/plugins");
|
||
}
|
||
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
|
||
export function adminSetPluginPolicy(
|
||
name: string,
|
||
allowInGroup: boolean,
|
||
allowInStranger: boolean
|
||
): Promise<AdminPluginRecord> {
|
||
return request<AdminPluginRecord>("/admin/plugins/policy", {
|
||
method: "POST",
|
||
body: JSON.stringify({ name, allowInGroup, allowInStranger }),
|
||
});
|
||
}
|
||
/** GET /api/v1/admin/channels */
|
||
export function adminListChannels(): Promise<AdminChannelRecord[]> {
|
||
return request<AdminChannelRecord[]>("/admin/channels");
|
||
}
|
||
/** GET /api/v1/admin/iot */
|
||
export function adminListIoT(): Promise<AdminIoTDevice[]> {
|
||
return request<AdminIoTDevice[]>("/admin/iot");
|
||
}
|
||
|
||
// --- Planned M10 endpoints (contract-shaped, backend may 404). ---
|
||
|
||
/** GET /api/v1/admin/users */
|
||
export function adminListUsers(): Promise<AdminUserRecord[]> {
|
||
return request<AdminUserRecord[]>("/admin/users");
|
||
}
|
||
/** GET /api/v1/admin/llm */
|
||
export function adminGetLlm(): Promise<AdminLlmConfig> {
|
||
return request<AdminLlmConfig>("/admin/llm");
|
||
}
|
||
/** GET /api/v1/admin/automations */
|
||
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
|
||
return request<AdminAutomationRecord[]>("/admin/automations");
|
||
}
|
||
/** GET /api/v1/admin/rag — lists knowledge documents; when query is present the
|
||
* backend also returns matching hits. */
|
||
export function adminListRag(query?: string): Promise<AdminRagResponse> {
|
||
const qs = query ? `?query=${encodeURIComponent(query)}` : "";
|
||
return request<AdminRagResponse>(`/admin/rag${qs}`);
|
||
}
|
||
/** POST /api/v1/admin/rag/query — runs a real knowledge-base query. */
|
||
export function adminSearchRag(
|
||
query: string
|
||
): Promise<{ query: string; hits: AdminRagHit[] }> {
|
||
return request<{ query: string; hits: AdminRagHit[] }>("/admin/rag/query", {
|
||
method: "POST",
|
||
body: JSON.stringify({ query }),
|
||
});
|
||
}
|
||
/** POST /api/v1/admin/rag/delete — deletes a document. The backend returns a
|
||
* clear 501 until M10 lands the write path (upload/chunk/embed/delete). */
|
||
export function adminDeleteRag(id: string): Promise<{ id: string }> {
|
||
return request<{ id: string }>("/admin/rag/delete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
}
|