feat: ship native boss android console
This commit is contained in:
42
src/app/api/auth/forgot-password/route.ts
Normal file
42
src/app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resetAccountPassword } from "@/lib/boss-data";
|
||||
|
||||
function messageForResetError(code: string) {
|
||||
switch (code) {
|
||||
case "INVALID_VERIFICATION_CODE":
|
||||
return "验证码不正确或已失效,请重新获取。";
|
||||
case "ACCOUNT_NOT_FOUND":
|
||||
return "账号不存在,请先确认输入是否正确。";
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
account?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
if (!body.account || !body.password || !body.confirmPassword || !body.code) {
|
||||
return NextResponse.json({ ok: false, message: "忘记密码页字段不完整。" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.password !== body.confirmPassword) {
|
||||
return NextResponse.json({ ok: false, message: "两次输入的新密码不一致。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await resetAccountPassword(body.account, body.password, body.code);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: "密码重置成功。旧设备登录态后续应被要求重新校验。",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? messageForResetError(error.message) : "密码重置失败,请稍后再试。";
|
||||
return NextResponse.json({ ok: false, message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
97
src/app/api/auth/login/route.ts
Normal file
97
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createPrimaryAdminSession, loginAccount } from "@/lib/boss-data";
|
||||
import { setAuthSessionCookie } from "@/lib/boss-auth";
|
||||
|
||||
function shouldAllowTemporaryAutoLogin() {
|
||||
return process.env.BOSS_AUTH_AUTO_LOGIN !== "0";
|
||||
}
|
||||
|
||||
function messageForLoginError(code: string) {
|
||||
switch (code) {
|
||||
case "ACCOUNT_NOT_FOUND":
|
||||
return "账号不存在,请先注册或确认输入是否正确。";
|
||||
case "PASSWORD_REQUIRED":
|
||||
return "账号密码登录时必须填写密码。";
|
||||
case "INVALID_ACCOUNT_OR_PASSWORD":
|
||||
return "账号或密码不正确。";
|
||||
case "VERIFICATION_CODE_REQUIRED":
|
||||
return "验证码登录时必须填写验证码。";
|
||||
case "INVALID_VERIFICATION_CODE":
|
||||
return "验证码不正确或已失效,请重新获取。";
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
account?: string;
|
||||
password?: string;
|
||||
code?: string;
|
||||
method?: "password" | "code";
|
||||
};
|
||||
|
||||
if (shouldAllowTemporaryAutoLogin()) {
|
||||
const result = await createPrimaryAdminSession();
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
account: result.account,
|
||||
displayName: result.displayName,
|
||||
loginMethod: result.loginMethod,
|
||||
role: result.role,
|
||||
sessionExpiresAt: result.sessionExpiresAt,
|
||||
...(request.headers.get("x-boss-native-app") === "1"
|
||||
? { restoreToken: result.restoreToken }
|
||||
: {}),
|
||||
message: "临时免验证登录已启用,正在进入会话首页。",
|
||||
});
|
||||
return setAuthSessionCookie(response, result.sessionToken, request);
|
||||
}
|
||||
|
||||
if (!body.account) {
|
||||
return NextResponse.json({ ok: false, message: "登录页至少需要填写账号。" }, { status: 400 });
|
||||
}
|
||||
|
||||
const method = body.method ?? (body.password ? "password" : "code");
|
||||
if (method === "password" && !body.password) {
|
||||
return NextResponse.json({ ok: false, message: "账号密码登录时必须填写密码。" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (method === "code" && !body.code) {
|
||||
return NextResponse.json({ ok: false, message: "验证码登录时必须填写验证码。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginAccount({
|
||||
account: body.account,
|
||||
password: body.password,
|
||||
code: body.code,
|
||||
method,
|
||||
});
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
account: result.account,
|
||||
displayName: result.displayName,
|
||||
loginMethod: result.loginMethod,
|
||||
role: result.role,
|
||||
sessionExpiresAt: result.sessionExpiresAt,
|
||||
...(request.headers.get("x-boss-native-app") === "1"
|
||||
? { restoreToken: result.restoreToken }
|
||||
: {}),
|
||||
message:
|
||||
method === "password"
|
||||
? "账号密码校验通过,正在进入会话首页。"
|
||||
: "验证码校验通过,正在进入会话首页。",
|
||||
});
|
||||
return setAuthSessionCookie(response, result.sessionToken, request);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "LOGIN_FAILED";
|
||||
if (message === "LOGIN_TEMPORARILY_LOCKED") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "当前账号登录失败次数过多,请 10 分钟后再试。" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: false, message: messageForLoginError(message) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
9
src/app/api/auth/logout/route.ts
Normal file
9
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { clearAuthSessionCookie } from "@/lib/boss-auth";
|
||||
import { revokeAuthSession } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
await revokeAuthSession(request.cookies.get("boss_session")?.value);
|
||||
const response = NextResponse.json({ ok: true, message: "已退出当前账号。" });
|
||||
return clearAuthSessionCookie(response, request);
|
||||
}
|
||||
42
src/app/api/auth/register/route.ts
Normal file
42
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { registerAccount } from "@/lib/boss-data";
|
||||
|
||||
function messageForRegisterError(code: string) {
|
||||
switch (code) {
|
||||
case "INVALID_VERIFICATION_CODE":
|
||||
return "验证码不正确或已失效,请重新获取。";
|
||||
case "ACCOUNT_ALREADY_EXISTS":
|
||||
return "该账号已经注册,可以直接去登录。";
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
account?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
if (!body.account || !body.password || !body.confirmPassword || !body.code) {
|
||||
return NextResponse.json({ ok: false, message: "注册页字段不完整。" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.password !== body.confirmPassword) {
|
||||
return NextResponse.json({ ok: false, message: "两次输入的密码不一致。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await registerAccount(body.account, body.password, body.code);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: "注册成功。现在可以回到登录页完成登录。",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? messageForRegisterError(error.message) : "注册失败,请稍后再试。";
|
||||
return NextResponse.json({ ok: false, message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
32
src/app/api/auth/restore/route.ts
Normal file
32
src/app/api/auth/restore/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { setAuthSessionCookie } from "@/lib/boss-auth";
|
||||
import { restoreAuthSession } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
restoreToken?: string;
|
||||
};
|
||||
|
||||
if (!body.restoreToken?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "RESTORE_TOKEN_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await restoreAuthSession(body.restoreToken.trim());
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "RESTORE_SESSION_NOT_FOUND" }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
session: {
|
||||
account: session.account,
|
||||
role: session.role,
|
||||
displayName: session.displayName,
|
||||
loginMethod: session.loginMethod,
|
||||
expiresAt: session.expiresAt,
|
||||
lastSeenAt: session.lastSeenAt,
|
||||
restoreToken: session.restoreToken,
|
||||
},
|
||||
});
|
||||
return setAuthSessionCookie(response, session.sessionToken, request);
|
||||
}
|
||||
62
src/app/api/auth/send-code/route.ts
Normal file
62
src/app/api/auth/send-code/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getVerificationDeliveryTarget,
|
||||
issueVerificationCode,
|
||||
recordVerificationDelivery,
|
||||
} from "@/lib/boss-data";
|
||||
import { deliverVerificationCode } from "@/lib/boss-mail";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
account?: string;
|
||||
purpose?: "login" | "register" | "forgot-password";
|
||||
};
|
||||
|
||||
if (!body.account || !body.purpose) {
|
||||
return NextResponse.json({ ok: false, message: "缺少账号或验证码用途。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await issueVerificationCode(body.account, body.purpose);
|
||||
const recipient = await getVerificationDeliveryTarget(body.account);
|
||||
const delivery = await deliverVerificationCode({
|
||||
account: body.account,
|
||||
recipient,
|
||||
purpose: body.purpose,
|
||||
code: record.code,
|
||||
});
|
||||
|
||||
await recordVerificationDelivery(
|
||||
body.account,
|
||||
body.purpose,
|
||||
delivery.delivered ? "delivered" : "failed",
|
||||
delivery.message,
|
||||
);
|
||||
|
||||
if (!delivery.delivered) {
|
||||
return NextResponse.json({ ok: false, message: delivery.message }, { status: delivery.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: `验证码已生成。${delivery.message}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "SEND_CODE_FAILED";
|
||||
if (message === "VERIFICATION_CODE_COOLDOWN") {
|
||||
await recordVerificationDelivery(body.account, body.purpose, "rate_limited", "60 秒冷却中");
|
||||
return NextResponse.json({ ok: false, message: "验证码刚发送过,请 60 秒后再试。" }, { status: 429 });
|
||||
}
|
||||
if (message === "VERIFICATION_RATE_LIMITED") {
|
||||
await recordVerificationDelivery(body.account, body.purpose, "rate_limited", "15 分钟窗口达到上限");
|
||||
return NextResponse.json({ ok: false, message: "当前账号验证码请求过于频繁,请 15 分钟后再试。" }, { status: 429 });
|
||||
}
|
||||
if (message === "ACCOUNT_NOT_FOUND") {
|
||||
return NextResponse.json({ ok: false, message: "当前账号不存在,无法发送验证码。" }, { status: 404 });
|
||||
}
|
||||
if (message === "ACCOUNT_ALREADY_EXISTS") {
|
||||
return NextResponse.json({ ok: false, message: "当前账号已注册,请直接登录。" }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ ok: false, message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
23
src/app/api/auth/session/route.ts
Normal file
23
src/app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getRequestSession } from "@/lib/boss-auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: {
|
||||
account: session.account,
|
||||
role: session.role,
|
||||
displayName: session.displayName,
|
||||
loginMethod: session.loginMethod,
|
||||
expiresAt: session.expiresAt,
|
||||
lastSeenAt: session.lastSeenAt,
|
||||
...(request.headers.get("x-boss-native-app") === "1"
|
||||
? { restoreToken: session.restoreToken }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
45
src/app/api/device-heartbeat/route.ts
Normal file
45
src/app/api/device-heartbeat/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { upsertDeviceHeartbeat } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
deviceId?: string;
|
||||
token?: string;
|
||||
pairingCode?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
account?: string;
|
||||
status?: "online" | "abnormal" | "offline";
|
||||
quota5h?: number;
|
||||
quota7d?: number;
|
||||
projects?: string[];
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!body.deviceId ||
|
||||
!body.name ||
|
||||
!body.avatar ||
|
||||
!body.account ||
|
||||
!body.status ||
|
||||
!body.projects
|
||||
) {
|
||||
return NextResponse.json({ ok: false, message: "heartbeat 字段不完整" }, { status: 400 });
|
||||
}
|
||||
|
||||
const device = await upsertDeviceHeartbeat({
|
||||
deviceId: body.deviceId,
|
||||
token: body.token,
|
||||
pairingCode: body.pairingCode,
|
||||
name: body.name,
|
||||
avatar: body.avatar,
|
||||
account: body.account,
|
||||
status: body.status,
|
||||
quota5h: body.quota5h ?? 0,
|
||||
quota7d: body.quota7d ?? 0,
|
||||
projects: body.projects,
|
||||
endpoint: body.endpoint,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, ...device });
|
||||
}
|
||||
9
src/app/api/health/route.ts
Normal file
9
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: "boss-web",
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { toggleGoal } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ projectId: string; goalId: string }> },
|
||||
) {
|
||||
const { projectId, goalId } = await context.params;
|
||||
|
||||
try {
|
||||
const goal = await toggleGoal(projectId, goalId);
|
||||
return NextResponse.json({ ok: true, goal });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/projects/[projectId]/goals/update/route.ts
Normal file
37
src/app/api/projects/[projectId]/goals/update/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createGoal, updateGoalText } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
goalId?: string;
|
||||
text?: string;
|
||||
action?: "create" | "update";
|
||||
};
|
||||
|
||||
if (!body.text) {
|
||||
return NextResponse.json({ ok: false, message: "缺少 text" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (body.action === "create") {
|
||||
const goal = await createGoal(projectId, body.text);
|
||||
return NextResponse.json({ ok: true, goal });
|
||||
}
|
||||
|
||||
if (!body.goalId) {
|
||||
return NextResponse.json({ ok: false, message: "缺少 goalId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const goal = await updateGoalText(projectId, body.goalId, body.text);
|
||||
return NextResponse.json({ ok: true, goal });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/app/api/state/route.ts
Normal file
18
src/app/api/state/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({
|
||||
...state,
|
||||
aiAccounts: state.aiAccounts.map(({ apiKey, ...account }) => ({
|
||||
...account,
|
||||
apiKey: apiKey ? "[REDACTED]" : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
29
src/app/api/v1/accounts/[accountId]/activate/route.ts
Normal file
29
src/app/api/v1/accounts/[accountId]/activate/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { activateAiAccount } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { reason?: string };
|
||||
const { accountId } = await context.params;
|
||||
|
||||
try {
|
||||
const result = await activateAiAccount(accountId, body.reason?.trim() || "手动切换主控身份");
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
116
src/app/api/v1/accounts/[accountId]/route.ts
Normal file
116
src/app/api/v1/accounts/[accountId]/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { deleteAiAccount, getAiAccount, saveAiAccount } from "@/lib/boss-data";
|
||||
|
||||
function isValidRole(value: string): value is "primary" | "backup" | "api_fallback" {
|
||||
return value === "primary" || value === "backup" || value === "api_fallback";
|
||||
}
|
||||
|
||||
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" {
|
||||
return value === "master_codex_node" || value === "openai_api";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { accountId } = await context.params;
|
||||
const account = await getAiAccount(accountId);
|
||||
if (!account) {
|
||||
return NextResponse.json({ ok: false, message: "AI_ACCOUNT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, account });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { accountId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
label?: string;
|
||||
role?: string;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
accountIdentifier?: string;
|
||||
nodeId?: string;
|
||||
nodeLabel?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
enabled?: boolean;
|
||||
setActive?: boolean;
|
||||
loginStatusNote?: string;
|
||||
};
|
||||
|
||||
if (!body.label?.trim() || !body.displayName?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "AI 账号至少需要填写显示名称和账号名称。" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.role || !isValidRole(body.role)) {
|
||||
return NextResponse.json({ ok: false, message: "AI 账号角色不合法。" }, { status: 400 });
|
||||
}
|
||||
if (!body.provider || !isValidProvider(body.provider)) {
|
||||
return NextResponse.json({ ok: false, message: "AI 账号类型不合法。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await saveAiAccount({
|
||||
accountId,
|
||||
label: body.label,
|
||||
role: body.role,
|
||||
provider: body.provider,
|
||||
displayName: body.displayName,
|
||||
accountIdentifier: body.accountIdentifier,
|
||||
nodeId: body.nodeId,
|
||||
nodeLabel: body.nodeLabel,
|
||||
model: body.model,
|
||||
apiKey: body.apiKey,
|
||||
enabled: body.enabled,
|
||||
setActive: body.setActive,
|
||||
loginStatusNote: body.loginStatusNote,
|
||||
});
|
||||
return NextResponse.json({ ok: true, account });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
const { accountId } = await context.params;
|
||||
try {
|
||||
await deleteAiAccount(accountId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/v1/accounts/[accountId]/validate/route.ts
Normal file
27
src/app/api/v1/accounts/[accountId]/validate/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { validateAiAccountConnection } from "@/lib/boss-master-agent";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountId } = await context.params;
|
||||
const result = await validateAiAccountConnection(accountId);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/app/api/v1/accounts/route.ts
Normal file
74
src/app/api/v1/accounts/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { listAiAccounts, saveAiAccount } from "@/lib/boss-data";
|
||||
|
||||
function isValidRole(value: string): value is "primary" | "backup" | "api_fallback" {
|
||||
return value === "primary" || value === "backup" || value === "api_fallback";
|
||||
}
|
||||
|
||||
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" {
|
||||
return value === "master_codex_node" || value === "openai_api";
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const result = await listAiAccounts();
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as {
|
||||
label?: string;
|
||||
role?: string;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
accountIdentifier?: string;
|
||||
nodeId?: string;
|
||||
nodeLabel?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
enabled?: boolean;
|
||||
setActive?: boolean;
|
||||
loginStatusNote?: string;
|
||||
};
|
||||
|
||||
if (!body.label?.trim() || !body.displayName?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "AI 账号至少需要填写显示名称和账号名称。" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.role || !isValidRole(body.role)) {
|
||||
return NextResponse.json({ ok: false, message: "AI 账号角色不合法。" }, { status: 400 });
|
||||
}
|
||||
if (!body.provider || !isValidProvider(body.provider)) {
|
||||
return NextResponse.json({ ok: false, message: "AI 账号类型不合法。" }, { status: 400 });
|
||||
}
|
||||
|
||||
const account = await saveAiAccount({
|
||||
label: body.label,
|
||||
role: body.role,
|
||||
provider: body.provider,
|
||||
displayName: body.displayName,
|
||||
accountIdentifier: body.accountIdentifier,
|
||||
nodeId: body.nodeId,
|
||||
nodeLabel: body.nodeLabel,
|
||||
model: body.model,
|
||||
apiKey: body.apiKey,
|
||||
enabled: body.enabled,
|
||||
setActive: body.setActive,
|
||||
loginStatusNote: body.loginStatusNote,
|
||||
});
|
||||
return NextResponse.json({ ok: true, account });
|
||||
}
|
||||
93
src/app/api/v1/app-logs/route.ts
Normal file
93
src/app/api/v1/app-logs/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { appendAppLog, readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(request.nextUrl.searchParams.get("limit") ?? "20"), 1), 100);
|
||||
const cursor = request.nextUrl.searchParams.get("cursor");
|
||||
const deviceId = request.nextUrl.searchParams.get("deviceId") ?? undefined;
|
||||
const projectId = request.nextUrl.searchParams.get("projectId") ?? undefined;
|
||||
const level = request.nextUrl.searchParams.get("level") ?? undefined;
|
||||
const category = request.nextUrl.searchParams.get("category") ?? undefined;
|
||||
const source = request.nextUrl.searchParams.get("source") ?? undefined;
|
||||
const state = await readState();
|
||||
|
||||
const filtered = state.appLogs
|
||||
.filter((entry) => {
|
||||
const device = state.devices.find((item) => item.id === entry.deviceId);
|
||||
if (
|
||||
session.role !== "highest_admin" &&
|
||||
device?.account !== session.account
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (deviceId && entry.deviceId !== deviceId) return false;
|
||||
if (projectId && entry.projectId !== projectId) return false;
|
||||
if (level && entry.level !== level) return false;
|
||||
if (category && entry.category !== category) return false;
|
||||
if (source && entry.source !== source) return false;
|
||||
if (cursor && entry.createdAt >= cursor) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
|
||||
const entries = filtered.slice(0, limit);
|
||||
const nextCursor = filtered.length > limit ? entries.at(-1)?.createdAt : null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
entries,
|
||||
nextCursor,
|
||||
hasMore: Boolean(nextCursor),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
deviceId?: string;
|
||||
projectId?: string;
|
||||
level?: "info" | "warn" | "error";
|
||||
source?: "app_client" | "local_agent";
|
||||
category?: string;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
mirrorToMaster?: boolean;
|
||||
};
|
||||
|
||||
if (!body.deviceId || !body.level || !body.category || !body.message) {
|
||||
return NextResponse.json({ ok: false, message: "APP 日志字段不完整。" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authorization = await authorizeDeviceWriteRequest(request, body.deviceId);
|
||||
if (!authorization.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED_DEVICE_WRITE" }, { status: 401 });
|
||||
}
|
||||
if (!authorization.device) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await appendAppLog({
|
||||
deviceId: body.deviceId,
|
||||
projectId: body.projectId,
|
||||
level: body.level,
|
||||
source: body.source ?? "app_client",
|
||||
category: body.category,
|
||||
message: body.message,
|
||||
detail: body.detail,
|
||||
mirrorToMaster: body.mirrorToMaster,
|
||||
});
|
||||
return NextResponse.json({ ok: true, entry });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/audits/summary/route.ts
Normal file
13
src/app/api/v1/audits/summary/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getAuditSummaryView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({ ok: true, ...getAuditSummaryView(state) });
|
||||
}
|
||||
29
src/app/api/v1/conversations/[projectId]/actions/route.ts
Normal file
29
src/app/api/v1/conversations/[projectId]/actions/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { updateConversationAction } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as { action?: "toggle_pin" | "mark_read" };
|
||||
|
||||
if (!body.action) {
|
||||
return NextResponse.json({ ok: false, message: "缺少 action" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await updateConversationAction(projectId, body.action);
|
||||
return NextResponse.json({ ok: true, project });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/api/v1/conversations/route.ts
Normal file
16
src/app/api/v1/conversations/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getConversationItems } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
conversations: getConversationItems(state),
|
||||
});
|
||||
}
|
||||
33
src/app/api/v1/devices/[deviceId]/route.ts
Normal file
33
src/app/api/v1/devices/[deviceId]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { updateDevice } from "@/lib/boss-data";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { deviceId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
account?: string;
|
||||
status?: "online" | "abnormal" | "offline";
|
||||
endpoint?: string;
|
||||
note?: string;
|
||||
projects?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
const device = await updateDevice(deviceId, body);
|
||||
return NextResponse.json({ ok: true, device });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/app/api/v1/devices/[deviceId]/skills/route.ts
Normal file
77
src/app/api/v1/devices/[deviceId]/skills/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { readState, upsertDeviceSkills } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { deviceId } = await context.params;
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === deviceId);
|
||||
if (!device) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
device,
|
||||
skills: state.deviceSkills
|
||||
.filter((item) => item.deviceId === deviceId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const { deviceId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
skills?: Array<{
|
||||
name?: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
invocation?: string;
|
||||
category?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!Array.isArray(body.skills)) {
|
||||
return NextResponse.json({ ok: false, message: "缺少技能列表。" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authorization = await authorizeDeviceWriteRequest(request, deviceId);
|
||||
if (!authorization.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED_DEVICE_WRITE" }, { status: 401 });
|
||||
}
|
||||
if (!authorization.device) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const skills = await upsertDeviceSkills({
|
||||
deviceId,
|
||||
skills: body.skills
|
||||
.filter((skill) => skill.name && skill.path)
|
||||
.map((skill) => ({
|
||||
name: skill.name as string,
|
||||
description: skill.description,
|
||||
path: skill.path as string,
|
||||
invocation: skill.invocation,
|
||||
category: skill.category,
|
||||
})),
|
||||
});
|
||||
return NextResponse.json({ ok: true, skills, count: skills.length });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/app/api/v1/devices/enrollments/route.ts
Normal file
51
src/app/api/v1/devices/enrollments/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createDeviceEnrollment, readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({ ok: true, enrollments: state.deviceEnrollments });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const body = (await request.json()) as {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
account?: string;
|
||||
endpoint?: string;
|
||||
note?: string;
|
||||
projects?: string[];
|
||||
};
|
||||
|
||||
if (!body.name || !body.avatar || !body.account) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "缺少设备名、头像或账号" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createDeviceEnrollment({
|
||||
name: body.name,
|
||||
avatar: body.avatar,
|
||||
account: body.account,
|
||||
endpoint: body.endpoint,
|
||||
note: body.note,
|
||||
projects: body.projects ?? [],
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
21
src/app/api/v1/devices/route.ts
Normal file
21
src/app/api/v1/devices/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getDeviceWorkspaceView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const deviceId = url.searchParams.get("device");
|
||||
const state = await readState();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
devices: state.devices,
|
||||
enrollments: state.deviceEnrollments,
|
||||
workspace: getDeviceWorkspaceView(state, deviceId ?? undefined),
|
||||
});
|
||||
}
|
||||
80
src/app/api/v1/events/route.ts
Normal file
80
src/app/api/v1/events/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { subscribeBossEvents } from "@/lib/boss-events";
|
||||
import { getAuditSummaryView, getConversationItems, getOpsSummaryView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function sseEvent(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ ok: false, message: "UNAUTHORIZED" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const publishSnapshots = async () => {
|
||||
const state = await readState();
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
sseEvent("conversation.context_indicator.updated", {
|
||||
at: new Date().toISOString(),
|
||||
conversations: getConversationItems(state),
|
||||
}),
|
||||
),
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
sseEvent("project.context_risk.updated", {
|
||||
at: new Date().toISOString(),
|
||||
ops: getOpsSummaryView(state),
|
||||
audits: getAuditSummaryView(state),
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
await publishSnapshots();
|
||||
|
||||
unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(sseEvent(event, payload)));
|
||||
} catch {
|
||||
unsubscribe?.();
|
||||
}
|
||||
});
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
unsubscribe?.();
|
||||
}
|
||||
}, 20_000);
|
||||
},
|
||||
cancel() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
44
src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts
Normal file
44
src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { completeMasterAgentTask } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ taskId: string }> },
|
||||
) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
deviceId?: string;
|
||||
status?: "completed" | "failed";
|
||||
replyBody?: string;
|
||||
errorMessage?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
if (!body.deviceId?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await authorizeDeviceWriteRequest(request, body.deviceId.trim());
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { taskId } = await context.params;
|
||||
|
||||
try {
|
||||
const task = await completeMasterAgentTask({
|
||||
taskId,
|
||||
deviceId: body.deviceId.trim(),
|
||||
status: body.status === "failed" ? "failed" : "completed",
|
||||
replyBody: body.replyBody,
|
||||
errorMessage: body.errorMessage,
|
||||
requestId: body.requestId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, task });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/app/api/v1/master-agent/tasks/claim/route.ts
Normal file
19
src/app/api/v1/master-agent/tasks/claim/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { claimNextMasterAgentTask } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => ({}))) as { deviceId?: string };
|
||||
const deviceId = body.deviceId?.trim();
|
||||
if (!deviceId) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await authorizeDeviceWriteRequest(request, deviceId);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const task = await claimNextMasterAgentTask(deviceId);
|
||||
return NextResponse.json({ ok: true, task });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { approveRepairTicket } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ ticketId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { ticketId } = await context.params;
|
||||
try {
|
||||
const ticket = await approveRepairTicket(ticketId);
|
||||
return NextResponse.json({ ok: true, ticket });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/v1/ops/repair-tickets/[ticketId]/verify/route.ts
Normal file
23
src/app/api/v1/ops/repair-tickets/[ticketId]/verify/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { verifyRepairTicket } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ ticketId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { ticketId } = await context.params;
|
||||
try {
|
||||
const ticket = await verifyRepairTicket(ticketId);
|
||||
return NextResponse.json({ ok: true, ticket });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/app/api/v1/ops/summary/route.ts
Normal file
13
src/app/api/v1/ops/summary/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getOpsSummaryView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({ ok: true, ...getOpsSummaryView(state) });
|
||||
}
|
||||
39
src/app/api/v1/projects/[projectId]/forwards/route.ts
Normal file
39
src/app/api/v1/projects/[projectId]/forwards/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { forwardProjectMessage } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
targetProjectId?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
if (!body.targetProjectId || !body.note) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "缺少 targetProjectId 或 note" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
targetProjectId: body.targetProjectId,
|
||||
note: body.note,
|
||||
});
|
||||
return NextResponse.json({ ok: true, message });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/v1/projects/[projectId]/goals/route.ts
Normal file
29
src/app/api/v1/projects/[projectId]/goals/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createGoal } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as { text?: string };
|
||||
|
||||
if (!body.text) {
|
||||
return NextResponse.json({ ok: false, message: "缺少 text" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const goal = await createGoal(projectId, body.text);
|
||||
return NextResponse.json({ ok: true, goal });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/v1/projects/[projectId]/messages/route.ts
Normal file
48
src/app/api/v1/projects/[projectId]/messages/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage } from "@/lib/boss-data";
|
||||
import { replyToMasterAgentUserMessage } from "@/lib/boss-master-agent";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
body?: string;
|
||||
kind?: "text" | "voice_intent" | "image_intent" | "video_intent";
|
||||
};
|
||||
|
||||
try {
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: body.kind ?? "text",
|
||||
});
|
||||
let masterReply:
|
||||
| { ok: boolean; reason?: string; message?: string; accountId?: string; requestId?: string }
|
||||
| undefined;
|
||||
|
||||
if (projectId === "master-agent" && (body.kind ?? "text") === "text" && message.body.trim()) {
|
||||
masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message, masterReply });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/v1/projects/[projectId]/route.ts
Normal file
23
src/app/api/v1/projects/[projectId]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getProjectDetailView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const detail = getProjectDetailView(state, projectId);
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, ...detail });
|
||||
}
|
||||
39
src/app/api/v1/settings/route.ts
Normal file
39
src/app/api/v1/settings/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState, updateUserSettings } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
settings: state.user.settings,
|
||||
user: state.user,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const body = (await request.json()) as {
|
||||
liveUpdates?: boolean;
|
||||
showRiskBadges?: boolean;
|
||||
confirmDangerousActions?: boolean;
|
||||
preferredEntryPoint?: "conversations" | "devices" | "me";
|
||||
};
|
||||
|
||||
try {
|
||||
const settings = await updateUserSettings(body);
|
||||
return NextResponse.json({ ok: true, settings });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/v1/threads/[threadId]/context-budget/route.ts
Normal file
23
src/app/api/v1/threads/[threadId]/context-budget/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getThreadContextDetailView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ threadId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { threadId } = await context.params;
|
||||
const state = await readState();
|
||||
const detail = getThreadContextDetailView(state, threadId);
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ ok: false, message: "THREAD_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, ...detail });
|
||||
}
|
||||
35
src/app/api/v1/user/ota/package/route.ts
Normal file
35
src/app/api/v1/user/ota/package/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getPublishedOtaAsset } from "@/lib/boss-ota";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ ok: false, message: "UNAUTHORIZED" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const asset = await getPublishedOtaAsset();
|
||||
if (!asset) {
|
||||
return new Response(JSON.stringify({ ok: false, message: "OTA_PACKAGE_NOT_FOUND" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const content = await fs.readFile(asset.absolutePath);
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.android.package-archive",
|
||||
"Content-Length": String(asset.sizeBytes),
|
||||
"Content-Disposition": `attachment; filename=\"${asset.fileName}\"`,
|
||||
ETag: asset.sha256,
|
||||
"X-Boss-Ota-Sha256": asset.sha256,
|
||||
"X-Boss-Ota-Updated-At": asset.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
45
src/app/api/v1/user/ota/route.ts
Normal file
45
src/app/api/v1/user/ota/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { checkForOta, getOtaStatus, performOta } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const status = await getOtaStatus();
|
||||
return NextResponse.json({ ok: true, ...status });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const raw = await request.text();
|
||||
const body = raw.trim() ? (JSON.parse(raw) as { action?: "check" | "apply" }) : {};
|
||||
const action = body.action ?? "apply";
|
||||
|
||||
try {
|
||||
if (action === "check") {
|
||||
const result = await checkForOta();
|
||||
return NextResponse.json({ ok: true, ...result, message: result.hasOta ? "发现新的 OTA 版本。" : "当前已经是最新版本。" });
|
||||
}
|
||||
|
||||
const result = await performOta();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
version: result.version,
|
||||
summary: result.summary,
|
||||
downloadUrl: result.downloadUrl,
|
||||
packageFileName: result.packageFileName,
|
||||
packageSizeBytes: result.packageSizeBytes,
|
||||
packageSha256: result.packageSha256,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
98
src/app/api/v1/workers/[workerId]/thread-context/route.ts
Normal file
98
src/app/api/v1/workers/[workerId]/thread-context/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { upsertThreadContextSnapshot } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ workerId: string }> },
|
||||
) {
|
||||
const { workerId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
nodeId?: string;
|
||||
threadId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
sourceKind?: "codex_app_server" | "codex_sdk" | "worker_estimator";
|
||||
status?: string;
|
||||
contextBudgetRemainingPct?: number;
|
||||
contextBudgetLevel?: "safe" | "watch" | "urgent" | "critical";
|
||||
compactionExpectedAt?: string;
|
||||
mustFinishBeforeCompaction?: boolean;
|
||||
estimatedRemainingTurns?: number;
|
||||
estimatedRemainingLargeMessages?: number;
|
||||
lastCompactionAt?: string;
|
||||
compactionCount?: number;
|
||||
patchPending?: boolean;
|
||||
testsPending?: boolean;
|
||||
evidencePending?: boolean;
|
||||
checklist?: string[];
|
||||
capturedAt?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!body.nodeId ||
|
||||
!body.threadId ||
|
||||
!body.projectId ||
|
||||
!body.taskId ||
|
||||
!body.title ||
|
||||
!body.summary ||
|
||||
typeof body.contextBudgetRemainingPct !== "number"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "thread-context 字段不完整" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const authorization = await authorizeDeviceWriteRequest(request, body.nodeId);
|
||||
if (!authorization.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED_DEVICE_WRITE" }, { status: 401 });
|
||||
}
|
||||
if (!authorization.device) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await upsertThreadContextSnapshot(workerId, {
|
||||
nodeId: body.nodeId,
|
||||
threadId: body.threadId,
|
||||
projectId: body.projectId,
|
||||
taskId: body.taskId,
|
||||
title: body.title,
|
||||
summary: body.summary,
|
||||
sourceKind: body.sourceKind ?? "worker_estimator",
|
||||
status: (body.status as
|
||||
| "idle"
|
||||
| "running"
|
||||
| "waiting_input"
|
||||
| "waiting_approval"
|
||||
| "context_watch"
|
||||
| "context_urgent"
|
||||
| "compacted"
|
||||
| "handoff_pending"
|
||||
| "completed"
|
||||
| "failed") ?? "running",
|
||||
contextBudgetRemainingPct: body.contextBudgetRemainingPct,
|
||||
contextBudgetLevel: body.contextBudgetLevel,
|
||||
compactionExpectedAt: body.compactionExpectedAt,
|
||||
mustFinishBeforeCompaction: Boolean(body.mustFinishBeforeCompaction),
|
||||
estimatedRemainingTurns: body.estimatedRemainingTurns ?? 0,
|
||||
estimatedRemainingLargeMessages: body.estimatedRemainingLargeMessages ?? 0,
|
||||
lastCompactionAt: body.lastCompactionAt,
|
||||
compactionCount: body.compactionCount ?? 0,
|
||||
patchPending: Boolean(body.patchPending),
|
||||
testsPending: Boolean(body.testsPending),
|
||||
evidencePending: Boolean(body.evidencePending),
|
||||
checklist: body.checklist ?? [],
|
||||
capturedAt: body.capturedAt ?? new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/app/auth/forgot-password/page.tsx
Normal file
17
src/app/auth/forgot-password/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AppShell, AuthForm, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { redirectIfAuthenticated } from "@/lib/boss-auth";
|
||||
|
||||
export default async function ForgotPasswordPage() {
|
||||
await redirectIfAuthenticated();
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="忘记密码" backHref="/auth/login" />
|
||||
<AuthForm
|
||||
mode="forgot-password"
|
||||
title="重置登录密码"
|
||||
description="通过验证码确认身份后,才能设置新的登录密码。验证码带有效期,使用后会立即失效。"
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
26
src/app/auth/help/page.tsx
Normal file
26
src/app/auth/help/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
|
||||
export default function AuthHelpPage() {
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="登录帮助" backHref="/auth/login" />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
登录链路当前已临时切到免验证模式。点击登录会直接进入系统,账号密码和验证码输入暂时不作为拦截条件。
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
默认管理员账号:`17600003315`
|
||||
<br />
|
||||
默认测试密码:`boss123456`
|
||||
<br />
|
||||
当前账号角色:`最高管理员`
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-4 text-[13px] leading-6 text-[#215B39]">
|
||||
当前仍保留固定验证码 `000000` 的 MVP 能力,但登录页已经临时放开成“一键进入”。
|
||||
等后续重新收口认证链路时,再恢复账号密码 / 验证码校验。
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
17
src/app/auth/login/page.tsx
Normal file
17
src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AppShell, AuthForm, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { redirectIfAuthenticated } from "@/lib/boss-auth";
|
||||
|
||||
export default async function LoginPage() {
|
||||
await redirectIfAuthenticated();
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="登录" rightLabel="帮助" rightHref="/auth/help" />
|
||||
<AuthForm
|
||||
mode="login"
|
||||
title="登录 Codex 协同"
|
||||
description="当前已临时切到免验证登录模式,点击登录会直接进入会话首页。账号密码和验证码输入暂时不作为拦截条件。"
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
17
src/app/auth/register/page.tsx
Normal file
17
src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AppShell, AuthForm, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { redirectIfAuthenticated } from "@/lib/boss-auth";
|
||||
|
||||
export default async function RegisterPage() {
|
||||
await redirectIfAuthenticated();
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="注册" backHref="/auth/login" />
|
||||
<AuthForm
|
||||
mode="register"
|
||||
title="创建账号"
|
||||
description="注册后可绑定自己的 Mac、Windows 或云端 Codex 节点。注册仍通过验证码确认身份,验证码会校验发送频率并在使用后失效。"
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
34
src/app/conversations/[projectId]/forward/page.tsx
Normal file
34
src/app/conversations/[projectId]/forward/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AppShell, ForwardComposer, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getProject, readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ForwardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const { projectId } = await params;
|
||||
const [project, state] = await Promise.all([getProject(projectId), readState()]);
|
||||
if (!project) notFound();
|
||||
|
||||
const targets = state.projects
|
||||
.filter((item) => item.id !== projectId)
|
||||
.map((item) => ({ id: item.id, name: item.name }));
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="转发到项目" backHref={`/conversations/${projectId}`} />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
当前来源项目:{project.name}
|
||||
</div>
|
||||
<ForwardComposer sourceProjectId={projectId} targets={targets} />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
53
src/app/conversations/[projectId]/goals/page.tsx
Normal file
53
src/app/conversations/[projectId]/goals/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AppShell,
|
||||
GoalChecklist,
|
||||
PageNav,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getProject } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GoalsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const { projectId } = await params;
|
||||
const project = await getProject(projectId);
|
||||
|
||||
if (!project) notFound();
|
||||
|
||||
const completedCount = project.goals.filter((item) => item.state === "completed").length;
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="项目目标" backHref={`/conversations/${projectId}`} />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[15px] font-semibold text-[#111111]">
|
||||
主 Agent 已整理项目目标 · 已完成 {completedCount}/{project.goals.length}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-[#8C8C8C]">
|
||||
最近更新 09:18 · 用户可编辑 · 点选圆圈标记完成后自动划线
|
||||
</div>
|
||||
</div>
|
||||
<GoalChecklist projectId={projectId} goals={project.goals} />
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-4">
|
||||
<div className="text-[14px] font-semibold text-[#215B39]">当前约束</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#215B39]">
|
||||
• 只能使用已绑定设备
|
||||
<br />
|
||||
• 审计证据必须可回放
|
||||
<br />
|
||||
• 版本记录仅主 Agent 可发布
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
153
src/app/conversations/[projectId]/page.tsx
Normal file
153
src/app/conversations/[projectId]/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import {
|
||||
AppShell,
|
||||
ChatBubble,
|
||||
ChatComposer,
|
||||
MasterIdentityPill,
|
||||
PageNav,
|
||||
ProjectHeaderActions,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProjectChatPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const { projectId } = await params;
|
||||
const state = await readState();
|
||||
const detail = getProjectDetailView(state, projectId);
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<RealtimeRefresh
|
||||
events={["project.messages.updated", "app.logs.updated", "project.context_risk.updated", "ota.updated"]}
|
||||
/>
|
||||
<StatusBar />
|
||||
<PageNav
|
||||
title={detail.project.name}
|
||||
backHref="/conversations"
|
||||
rightNode={detail.masterIdentity ? <MasterIdentityPill identity={detail.masterIdentity} /> : undefined}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-[18px] pb-0">
|
||||
<div className="text-[12px] text-[#8C8C8C]">
|
||||
设备:{detail.devices.map((item) => item.name).join(" / ")}
|
||||
</div>
|
||||
{detail.masterIdentity ? (
|
||||
<div className="mt-2 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
当前身份:<span className="font-semibold text-[#111111]">{detail.masterIdentity.roleLabel}</span>
|
||||
{" · "}
|
||||
{detail.masterIdentity.displayName}
|
||||
{detail.masterIdentity.nodeLabel ? ` · ${detail.masterIdentity.nodeLabel}` : ""}
|
||||
<br />
|
||||
状态:{detail.masterIdentity.statusLabel}
|
||||
{detail.masterIdentity.model ? ` · 模型 ${detail.masterIdentity.model}` : ""}
|
||||
{detail.masterIdentity.lastSwitchedAt
|
||||
? ` · 最近切换 ${formatTimestampLabel(detail.masterIdentity.lastSwitchedAt)}`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pt-3">
|
||||
<ProjectHeaderActions projectId={detail.project.id} />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">主 Agent 调度结论</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{detail.masterContextStrategySummary}
|
||||
</div>
|
||||
</div>
|
||||
{detail.activeThreadContexts.map((thread) => (
|
||||
<Link
|
||||
key={thread.snapshot.threadId}
|
||||
href={`/threads/${thread.snapshot.threadId}`}
|
||||
className="block rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-[#111111]">
|
||||
{thread.snapshot.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{thread.snapshot.workerId} · {thread.snapshot.nodeId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">
|
||||
{thread.snapshot.contextBudgetRemainingPct}%
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">
|
||||
{thread.snapshot.contextBudgetLevel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{thread.snapshot.summary}
|
||||
</div>
|
||||
{thread.handoffPackage ? (
|
||||
<div className="mt-2 rounded-xl bg-[#FFF7E6] px-3 py-2 text-[12px] text-[#D46B08]">
|
||||
handoff: {thread.handoffPackage.packageStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">实时 APP 日志</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">
|
||||
{detail.recentAppLogs.length ? "已同步到服务器" : "等待客户端日志"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{detail.recentAppLogs.length ? (
|
||||
detail.recentAppLogs.map((log) => (
|
||||
<div key={log.logId} className="rounded-2xl bg-[#F7F8FA] px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3 text-[12px] text-[#8C8C8C]">
|
||||
<span>
|
||||
{log.deviceId} · {log.category}
|
||||
</span>
|
||||
<span>{formatTimestampLabel(log.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#111111]">{log.message}</div>
|
||||
{log.detail ? (
|
||||
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">{log.detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl bg-[#FFF7E6] px-3 py-3 text-[12px] leading-6 text-[#D46B08]">
|
||||
当前还没有新的 APP 端日志。打开页面、发送消息、复制 Skill 或执行 OTA 检查后,这里会实时刷新。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
|
||||
{detail.project.messages.map((message) => (
|
||||
<ChatBubble key={message.id} message={message} />
|
||||
))}
|
||||
<div className="rounded-2xl bg-white px-4 py-3 text-[13px] leading-6 text-[#57606A]">
|
||||
语音、图片、视频和转发入口已经接到当前消息账本。对象存储和真实媒体文件仍保持 MVP 占位。
|
||||
</div>
|
||||
<Link
|
||||
href={`/conversations/${detail.project.id}/versions`}
|
||||
className="block rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[13px] leading-6 text-[#215B39]"
|
||||
>
|
||||
版本迭代记录由主 Agent 自动整理并复核,只读发布。
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ChatComposer projectId={detail.project.id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
43
src/app/conversations/[projectId]/versions/page.tsx
Normal file
43
src/app/conversations/[projectId]/versions/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AppShell,
|
||||
PageNav,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getProject } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VersionsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const { projectId } = await params;
|
||||
const project = await getProject(projectId);
|
||||
|
||||
if (!project) notFound();
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="版本迭代记录" backHref={`/conversations/${projectId}`} />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
版本记录由主 Agent 监督各线程提交,并在复核后自动发布。当前页面只读,不允许人工直接篡改正文。
|
||||
</div>
|
||||
{project.versions.map((item) => (
|
||||
<div key={item.version} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{item.version}</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">{item.createdAt}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[14px] leading-6 text-[#57606A]">{item.summary}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
33
src/app/conversations/page.tsx
Normal file
33
src/app/conversations/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import {
|
||||
AppShell,
|
||||
ConversationList,
|
||||
HeaderTitle,
|
||||
LogoutButton,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getConversationItems } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ConversationsPage() {
|
||||
await requirePageSession();
|
||||
const state = await readState();
|
||||
const conversations = getConversationItems(state);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<RealtimeRefresh events={["conversation.updated", "project.messages.updated", "app.logs.updated"]} />
|
||||
<StatusBar />
|
||||
<HeaderTitle
|
||||
title="会话"
|
||||
extra={
|
||||
<LogoutButton label="退出" compact />
|
||||
}
|
||||
/>
|
||||
<ConversationList conversations={conversations} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
21
src/app/devices/add/page.tsx
Normal file
21
src/app/devices/add/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AppShell, DeviceEnrollmentBuilder, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
|
||||
export default async function AddDevicePage() {
|
||||
await requirePageSession();
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="添加设备" backHref="/devices" />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold">绑定新设备</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入。
|
||||
</div>
|
||||
</div>
|
||||
<DeviceEnrollmentBuilder />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
62
src/app/devices/page.tsx
Normal file
62
src/app/devices/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import {
|
||||
AppShell,
|
||||
DeviceEditorCard,
|
||||
DeviceList,
|
||||
HeaderTitle,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getDeviceWorkspaceView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DevicesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ device?: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const state = await readState();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const selectedDeviceId = resolvedSearchParams.device;
|
||||
const workspace = getDeviceWorkspaceView(state, selectedDeviceId);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<RealtimeRefresh events={["devices.updated", "devices.skills.updated", "project.context_risk.updated"]} />
|
||||
<StatusBar />
|
||||
<HeaderTitle
|
||||
title="设备"
|
||||
extra={
|
||||
<Link href="/devices/add" className="text-[20px] font-semibold text-[#07C160]">
|
||||
+
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="px-[18px] pb-3 text-[12px] text-[#8C8C8C]">
|
||||
当前只展示已接入生产链路的设备。演示脏数据已从正式设备视图中剔除,长按设备可编辑设备名称、头像和解绑设备。
|
||||
</div>
|
||||
{state.devices.length ? (
|
||||
<DeviceList devices={state.devices} />
|
||||
) : (
|
||||
<div className="px-[18px] pb-5">
|
||||
<div className="rounded-2xl border border-dashed border-[#D9D9D9] bg-white px-4 py-5 text-[13px] leading-6 text-[#57606A]">
|
||||
当前还没有生产设备接入。请先启动本机 `local-agent` 或通过设备绑定页生成 pairing code。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{workspace.selectedDevice ? (
|
||||
<div className="px-[18px] pb-5">
|
||||
<DeviceEditorCard
|
||||
device={workspace.selectedDevice}
|
||||
relatedThreads={workspace.relatedThreads}
|
||||
activeEnrollment={workspace.activeEnrollment}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--boss-bg: #f5f5f7;
|
||||
--boss-card: #ffffff;
|
||||
--boss-border: #e5e5ea;
|
||||
--boss-text: #111111;
|
||||
--boss-subtle: #8c8c8c;
|
||||
--boss-green: #07c160;
|
||||
--boss-green-soft: #eaf7f0;
|
||||
--boss-abnormal: #ff4d4f;
|
||||
--boss-offline: #b8b8b8;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
background-color: #eef2eb;
|
||||
background-image: url("/boss-app-bg.svg");
|
||||
background-position: center top;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
color: var(--boss-text);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue",
|
||||
Helvetica, Arial, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.boss-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.boss-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(17, 17, 17, 0.12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { AppLogBridge, NativeAppBridge } from "@/components/app-runtime";
|
||||
import { getCurrentPageSession } from "@/lib/boss-auth";
|
||||
import { getPreferredDeviceIdForAccountFromState, readState } from "@/lib/boss-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Boss 控制台",
|
||||
description: "多设备 Codex 协作控制台 MVP",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: "#eef2eb",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getCurrentPageSession();
|
||||
const state = session ? await readState() : null;
|
||||
const boundDeviceId =
|
||||
session && state
|
||||
? getPreferredDeviceIdForAccountFromState(
|
||||
state,
|
||||
session.account,
|
||||
state.user.boundDeviceId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="zh-CN">
|
||||
<body data-bound-device-id={boundDeviceId ?? ""}>
|
||||
<Suspense fallback={null}>
|
||||
<NativeAppBridge />
|
||||
</Suspense>
|
||||
<AppLogBridge deviceId={boundDeviceId} />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/app/me/about/page.tsx
Normal file
37
src/app/me/about/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { AppShell, OtaCenterCard, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getOtaStatus, readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AboutPage() {
|
||||
await requirePageSession();
|
||||
const [state, otaStatus] = await Promise.all([readState(), getOtaStatus()]);
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="关于 / OTA" backHref="/me" />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold">当前版本</div>
|
||||
<div className="mt-2 text-[14px] leading-6 text-[#57606A]">
|
||||
{state.user.version}
|
||||
<br />
|
||||
当前账号:{state.user.account}
|
||||
<br />
|
||||
绑定 Codex:{state.user.boundCodexNodeLabel ?? "未绑定"}
|
||||
</div>
|
||||
</div>
|
||||
<OtaCenterCard
|
||||
currentVersion={state.user.version}
|
||||
availableRelease={otaStatus.availableRelease}
|
||||
logs={otaStatus.logs}
|
||||
boundCodexNodeLabel={state.user.boundCodexNodeLabel}
|
||||
roleLabel={state.user.roleLabel}
|
||||
canApply={state.user.role === "highest_admin"}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
25
src/app/me/ai-accounts/page.tsx
Normal file
25
src/app/me/ai-accounts/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AppShell, HeaderTitle, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { AiAccountsClient } from "@/components/ai-accounts-client";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { listAiAccounts } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AiAccountsPage() {
|
||||
const session = await requirePageSession();
|
||||
const data = await listAiAccounts();
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="AI 账号" backHref="/me" />
|
||||
<HeaderTitle title="主控与账号状态" />
|
||||
<AiAccountsClient
|
||||
accounts={data.accounts}
|
||||
activeIdentity={data.activeIdentity}
|
||||
switchHistory={data.switchHistory}
|
||||
canManage={session.role === "highest_admin"}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
82
src/app/me/ops/audit/page.tsx
Normal file
82
src/app/me/ops/audit/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import Link from "next/link";
|
||||
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getAuditSummaryView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuditPage() {
|
||||
await requirePageSession();
|
||||
const state = await readState();
|
||||
const summary = getAuditSummaryView(state);
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="运维与修复" backHref="/me/ops" />
|
||||
<div className="flex min-h-0 flex-1 flex-col px-[18px] pb-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
href="/me/ops"
|
||||
className="flex h-10 items-center justify-center rounded-full bg-white text-[14px] font-semibold text-[#111111]"
|
||||
>
|
||||
运维对话
|
||||
</Link>
|
||||
<Link
|
||||
href="/me/ops/audit"
|
||||
className="flex h-10 items-center justify-center rounded-full bg-[#07C160] text-[14px] font-semibold text-white"
|
||||
>
|
||||
审计对话
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
|
||||
{summary.pendingRequests.map((request) => (
|
||||
<div
|
||||
key={request.auditRequestId}
|
||||
className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-[#111111]">
|
||||
{request.auditType} audit
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{request.projectName} · priority {request.priority}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">{request.trigger}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{request.objective}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{summary.latestResults.map((result) => (
|
||||
<div
|
||||
key={result.auditRequestId}
|
||||
className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">
|
||||
审计结论:{result.decision}
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">{result.status}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">{result.summary}</div>
|
||||
<div className="mt-2 text-[12px] leading-5 text-[#8C8C8C]">
|
||||
findings:{result.findings.join(";")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">能力注册表</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{summary.capabilities.map((capability) => `${capability.displayName} (${capability.status})`).join(";")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
72
src/app/me/ops/page.tsx
Normal file
72
src/app/me/ops/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Link from "next/link";
|
||||
import { AppShell, PageNav, RepairTicketActions, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getOpsSummaryView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OpsPage() {
|
||||
await requirePageSession();
|
||||
const state = await readState();
|
||||
const summary = getOpsSummaryView(state);
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="运维与修复" backHref="/me" />
|
||||
<div className="flex min-h-0 flex-1 flex-col px-[18px] pb-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
href="/me/ops"
|
||||
className="flex h-10 items-center justify-center rounded-full bg-[#07C160] text-[14px] font-semibold text-white"
|
||||
>
|
||||
运维对话
|
||||
</Link>
|
||||
<Link
|
||||
href="/me/ops/audit"
|
||||
className="flex h-10 items-center justify-center rounded-full bg-white text-[14px] font-semibold text-[#111111]"
|
||||
>
|
||||
审计对话
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">当前巡检模式</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{summary.mode === "active"
|
||||
? "高频 active:当前存在风险线程或未关闭运维工单。"
|
||||
: "idle:当前没有高风险工单,保持低频巡检。"}
|
||||
</div>
|
||||
</div>
|
||||
{summary.faults.map((fault) => (
|
||||
<div key={fault.faultId} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-[#111111]">{fault.faultKey}</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{fault.nodeId} · {fault.serviceName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">{fault.status}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">{fault.summary}</div>
|
||||
<div className="mt-2 text-[12px] leading-5 text-[#8C8C8C]">
|
||||
建议动作:{fault.suggestedNextAction}
|
||||
</div>
|
||||
{summary.tickets
|
||||
.filter((ticket) => ticket.faultId === fault.faultId)
|
||||
.map((ticket) => (
|
||||
<RepairTicketActions
|
||||
key={ticket.ticketId}
|
||||
ticket={ticket}
|
||||
verification={ticket.verification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
63
src/app/me/page.tsx
Normal file
63
src/app/me/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AppShell,
|
||||
HeaderTitle,
|
||||
MenuRow,
|
||||
OtaCenterCard,
|
||||
ProfileHero,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getOtaStatus, readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MePage() {
|
||||
await requirePageSession();
|
||||
const [state, otaStatus] = await Promise.all([readState(), getOtaStatus()]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<StatusBar />
|
||||
<HeaderTitle title="我的" />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-5">
|
||||
<ProfileHero user={state.user} />
|
||||
<MenuRow
|
||||
href="/me/security"
|
||||
title="账号与安全"
|
||||
description="修改登录密码、设备安全与身份校验"
|
||||
/>
|
||||
<MenuRow href="/me/settings" title="设置" description="实时刷新、风险徽标和默认首页" />
|
||||
<MenuRow
|
||||
href="/me/ops"
|
||||
title="运维与修复"
|
||||
description="查看运维对话、审计对话、修复回放与 standby 切换"
|
||||
/>
|
||||
<MenuRow
|
||||
href="/me/ai-accounts"
|
||||
title="AI 账号"
|
||||
description="管理主 GPT、备用 GPT、ChatGPT Plus / Codex 节点与 API 容灾"
|
||||
/>
|
||||
<MenuRow
|
||||
href="/me/skills"
|
||||
title="技能"
|
||||
description="按绑定设备查看已同步 Skill,并一键复制调用语句"
|
||||
/>
|
||||
<MenuRow
|
||||
href="/me/about"
|
||||
title="关于"
|
||||
description={`当前版本 ${state.user.version}`}
|
||||
badge={state.user.hasOta ? <span className="text-[12px] text-[#FF3B30]">OTA</span> : null}
|
||||
/>
|
||||
<OtaCenterCard
|
||||
currentVersion={state.user.version}
|
||||
availableRelease={otaStatus.availableRelease}
|
||||
logs={otaStatus.logs}
|
||||
boundCodexNodeLabel={state.user.boundCodexNodeLabel}
|
||||
roleLabel={state.user.roleLabel}
|
||||
canApply={state.user.role === "highest_admin"}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
29
src/app/me/security/page.tsx
Normal file
29
src/app/me/security/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { AppShell, LogoutButton, MenuRow, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const session = await requirePageSession();
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="账号与安全" backHref="/me" />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-6">
|
||||
<MenuRow href="/auth/forgot-password" title="修改账号密码" description="通过验证码重置登录密码" />
|
||||
<MenuRow href="/devices" title="设备安全管理" description="查看设备绑定、登录状态和剩余额度" />
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-4 text-[13px] leading-6 text-[#215B39]">
|
||||
当前登录页默认使用账号密码登录,也支持切换到验证码登录。验证码登录不需要密码,
|
||||
最高管理员账号 `17600003315` 已绑定到本机 Codex。当前固定验证码模式下,登录页可直接输入
|
||||
`000000` 登录。
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
当前登录账号:<span className="font-semibold text-[#111111]">{session.account}</span>
|
||||
<br />
|
||||
登录方式:{session.loginMethod === "password" ? "账号密码" : "验证码"}
|
||||
<br />
|
||||
会话到期:{new Date(session.expiresAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
</div>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
23
src/app/me/settings/page.tsx
Normal file
23
src/app/me/settings/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AppShell, PageNav, SettingsForm, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
await requirePageSession();
|
||||
const state = await readState();
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="设置" backHref="/me" />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
当前设置会持久化到 `data/boss-state.json`,下一线程接手时不会丢失。
|
||||
</div>
|
||||
<SettingsForm settings={state.user.settings} />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
33
src/app/me/skills/page.tsx
Normal file
33
src/app/me/skills/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { RealtimeRefresh, SkillInventoryPanel } from "@/components/app-runtime";
|
||||
import { AppShell } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getPreferredDeviceIdForAccount, readState } from "@/lib/boss-data";
|
||||
import { getSkillInventoryViewForAccount } from "@/lib/boss-projections";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SkillsPage() {
|
||||
const session = await requirePageSession();
|
||||
const state = await readState();
|
||||
const boundDeviceId = await getPreferredDeviceIdForAccount(
|
||||
session.account,
|
||||
state.user.boundDeviceId,
|
||||
);
|
||||
const inventory = getSkillInventoryViewForAccount(state, session.account, boundDeviceId);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<RealtimeRefresh events={["devices.skills.updated", "devices.updated", "app.logs.updated"]} />
|
||||
<StatusBar />
|
||||
<PageNav title="技能" backHref="/me" />
|
||||
<div className="flex flex-col gap-4 px-[18px] pb-5">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
当前页按设备查看 Skill 清单。Skill 由每台设备自己的 local-agent 从本机 `~/.codex/skills`
|
||||
扫描并同步,包含系统 Skill 与自定义 Skill。
|
||||
</div>
|
||||
<SkillInventoryPanel groups={inventory.groups} boundDeviceId={inventory.boundDeviceId} />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentPageSession } from "@/lib/boss-auth";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default async function Home() {
|
||||
const session = await getCurrentPageSession();
|
||||
redirect(session ? "/conversations" : "/auth/login");
|
||||
}
|
||||
|
||||
90
src/app/threads/[threadId]/page.tsx
Normal file
90
src/app/threads/[threadId]/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getThreadContextDetailView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ThreadPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ threadId: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const { threadId } = await params;
|
||||
const state = await readState();
|
||||
const detail = getThreadContextDetailView(state, threadId);
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="线程详情" backHref={`/conversations/${detail.snapshot.projectId}`} />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">
|
||||
{detail.snapshot.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{detail.snapshot.workerId} · {detail.snapshot.nodeId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[18px] font-semibold text-[#111111]">
|
||||
{detail.snapshot.contextBudgetRemainingPct}%
|
||||
</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">
|
||||
{detail.snapshot.contextBudgetLevel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[13px] leading-6 text-[#57606A]">
|
||||
{detail.snapshot.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">压缩前收尾清单</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{detail.currentChecklist.map((item) => `• ${item}`).join("\n")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">主 Agent 动作</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{detail.masterActions.map((item) => `• ${item}`).join("\n") || "当前无额外动作。"}
|
||||
</div>
|
||||
</div>
|
||||
{detail.handoffPackage ? (
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">handoff package</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">{detail.handoffPackage.packageStatus}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{detail.handoffPackage.summaryText}
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] leading-5 text-[#8C8C8C]">
|
||||
critical commands:{detail.handoffPackage.criticalCommands.join(";") || "暂无"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.alerts.map((alert) => (
|
||||
<div key={alert.alertId} className="rounded-2xl bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#D46B08]">
|
||||
{alert.summary}
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
href={`/api/v1/threads/${threadId}/context-budget`}
|
||||
className="block rounded-2xl bg-[#F5F5F7] px-4 py-4 text-[12px] text-[#57606A]"
|
||||
>
|
||||
打开 JSON 接口:/api/v1/threads/{threadId}/context-budget
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
627
src/components/ai-accounts-client.tsx
Normal file
627
src/components/ai-accounts-client.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import type {
|
||||
AiAccountRole,
|
||||
AiAccountSummary,
|
||||
AiAccountSwitchRecord,
|
||||
AiProvider,
|
||||
MasterIdentitySummary,
|
||||
} from "@/lib/boss-data";
|
||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
||||
|
||||
type AccountDraft = {
|
||||
label: string;
|
||||
role: AiAccountRole;
|
||||
provider: AiProvider;
|
||||
displayName: string;
|
||||
accountIdentifier: string;
|
||||
nodeId: string;
|
||||
nodeLabel: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
loginStatusNote: string;
|
||||
};
|
||||
|
||||
function roleOptions() {
|
||||
return [
|
||||
{ value: "primary", label: "主 GPT" },
|
||||
{ value: "backup", label: "备用 GPT" },
|
||||
{ value: "api_fallback", label: "API 容灾" },
|
||||
] as const;
|
||||
}
|
||||
|
||||
function providerOptions() {
|
||||
return [
|
||||
{ value: "openai_api", label: "OpenAI API" },
|
||||
{ value: "master_codex_node", label: "Master Codex Node / ChatGPT Plus 节点" },
|
||||
] as const;
|
||||
}
|
||||
|
||||
function emptyDraft(): AccountDraft {
|
||||
return {
|
||||
label: "API 容灾",
|
||||
role: "api_fallback",
|
||||
provider: "openai_api",
|
||||
displayName: "",
|
||||
accountIdentifier: "",
|
||||
nodeId: "",
|
||||
nodeLabel: "",
|
||||
model: "gpt-5.4",
|
||||
apiKey: "",
|
||||
enabled: true,
|
||||
loginStatusNote: "",
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromAccount(account: AiAccountSummary): AccountDraft {
|
||||
return {
|
||||
label: account.label,
|
||||
role: account.role,
|
||||
provider: account.provider,
|
||||
displayName: account.displayName,
|
||||
accountIdentifier: account.accountIdentifier || "",
|
||||
nodeId: account.nodeId || "",
|
||||
nodeLabel: account.nodeLabel || "",
|
||||
model: account.model || "gpt-5.4",
|
||||
apiKey: "",
|
||||
enabled: account.enabled,
|
||||
loginStatusNote: account.loginStatusNote || "",
|
||||
};
|
||||
}
|
||||
|
||||
function statusClasses(role: MasterIdentitySummary["role"]) {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
return "bg-[#EEF5FF] text-[#2457C5]";
|
||||
case "backup":
|
||||
return "bg-[#FFF5E8] text-[#B54708]";
|
||||
case "api_fallback":
|
||||
return "bg-[#F3F4F6] text-[#4B5563]";
|
||||
default:
|
||||
return "bg-[#F3F4F6] text-[#4B5563]";
|
||||
}
|
||||
}
|
||||
|
||||
function roleBadgeClasses(role: AiAccountRole) {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
return "bg-[#EEF5FF] text-[#2457C5]";
|
||||
case "backup":
|
||||
return "bg-[#FFF5E8] text-[#B54708]";
|
||||
case "api_fallback":
|
||||
return "bg-[#F3F4F6] text-[#4B5563]";
|
||||
default:
|
||||
return "bg-[#F3F4F6] text-[#4B5563]";
|
||||
}
|
||||
}
|
||||
|
||||
function AccountField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type?: "text" | "password";
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiAccountsClient({
|
||||
accounts,
|
||||
activeIdentity,
|
||||
switchHistory,
|
||||
canManage,
|
||||
}: {
|
||||
accounts: AiAccountSummary[];
|
||||
activeIdentity: MasterIdentitySummary;
|
||||
switchHistory: AiAccountSwitchRecord[];
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [drafts, setDrafts] = useState<Record<string, AccountDraft>>({});
|
||||
const [newDraft, setNewDraft] = useState<AccountDraft>(emptyDraft());
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const accountDrafts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
accounts.map((account) => [account.accountId, drafts[account.accountId] ?? draftFromAccount(account)]),
|
||||
),
|
||||
[accounts, drafts],
|
||||
);
|
||||
|
||||
function updateDraft(accountId: string, updater: (draft: AccountDraft) => AccountDraft) {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[accountId]: updater(current[accountId] ?? draftFromAccount(accounts.find((item) => item.accountId === accountId)!)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveAccount(accountId?: string) {
|
||||
const isNew = !accountId;
|
||||
const draft = isNew ? newDraft : accountDrafts[accountId];
|
||||
if (!draft.displayName.trim()) {
|
||||
setMessage("AI 账号名称不能为空。");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyKey(`${isNew ? "create" : "save"}:${accountId ?? "new"}`);
|
||||
const response = await fetch(isNew ? "/api/v1/accounts" : `/api/v1/accounts/${accountId}`, {
|
||||
method: isNew ? "POST" : "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(
|
||||
result.ok
|
||||
? isNew
|
||||
? "AI 账号已创建。"
|
||||
: "AI 账号已更新。"
|
||||
: result.message || "AI 账号保存失败。",
|
||||
);
|
||||
if (result.ok) {
|
||||
if (isNew) {
|
||||
setNewDraft(emptyDraft());
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function activateAccount(accountId: string) {
|
||||
setBusyKey(`activate:${accountId}`);
|
||||
const response = await fetch(`/api/v1/accounts/${accountId}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "在手机端手动切换主控身份" }),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "主控身份已切换。" : result.message || "切换失败。");
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAccount(accountId: string) {
|
||||
setBusyKey(`validate:${accountId}`);
|
||||
const response = await fetch(`/api/v1/accounts/${accountId}/validate`, {
|
||||
method: "POST",
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? result.message || "连接测试通过。" : result.message || "连接测试失败。");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function removeAccount(accountId: string) {
|
||||
setBusyKey(`delete:${accountId}`);
|
||||
const response = await fetch(`/api/v1/accounts/${accountId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "AI 账号已删除。" : result.message || "删除失败。");
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">当前主控身份</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{activeIdentity.displayName}
|
||||
{activeIdentity.nodeLabel ? ` · ${activeIdentity.nodeLabel}` : ""}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{activeIdentity.providerLabel}
|
||||
{activeIdentity.model ? ` · ${activeIdentity.model}` : ""}
|
||||
{activeIdentity.lastSwitchedAt
|
||||
? ` · 最近切换 ${formatTimestampLabel(activeIdentity.lastSwitchedAt)}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-1 text-[12px] font-semibold",
|
||||
statusClasses(activeIdentity.role),
|
||||
)}
|
||||
>
|
||||
{activeIdentity.roleLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
状态:{activeIdentity.statusLabel}
|
||||
<br />
|
||||
{activeIdentity.note}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">设计约束</div>
|
||||
<div className="mt-2 text-[12px] leading-6 text-[#57606A]">
|
||||
原始设计里的“主 GPT / 备用 GPT”不是纯 API 概念,主链路优先走单独在线的 Master
|
||||
Codex Node,也就是已经在对应电脑上登录了 ChatGPT Plus / Codex 的执行节点。当前生产链路已经是
|
||||
<span className="font-medium text-[#111111]">
|
||||
Boss Web -> task queue -> local-agent -> codex exec -> 回写项目账本
|
||||
</span>
|
||||
,OpenAI API 只作为用户可配置的容灾补位。
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
如果你要新增一个“主 GPT / 备用 GPT”,这里直接新增多个
|
||||
<span className="font-medium text-[#111111]"> Master Codex Node </span>
|
||||
账号即可;真正的登录动作发生在那台绑定电脑上的 Codex / ChatGPT 会话里,APP 负责展示、切换和回退。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accounts.map((account) => {
|
||||
const draft = accountDrafts[account.accountId];
|
||||
const locked = account.isEnvironmentFallback || !canManage;
|
||||
return (
|
||||
<div key={account.accountId} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{account.label}</div>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-2 py-1 text-[11px] font-semibold",
|
||||
roleBadgeClasses(account.role),
|
||||
)}
|
||||
>
|
||||
{account.roleLabel}
|
||||
</span>
|
||||
{account.isEnvironmentFallback ? (
|
||||
<span className="rounded-full bg-[#EAF7F0] px-2 py-1 text-[11px] font-semibold text-[#215B39]">
|
||||
环境变量
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{account.providerLabel}
|
||||
{account.nodeLabel ? ` · ${account.nodeLabel}` : ""}
|
||||
{account.model ? ` · ${account.model}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{account.isActive ? (
|
||||
<span className="rounded-full bg-[#111111] px-3 py-1 text-[11px] font-semibold text-white">
|
||||
当前主控
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<AccountField
|
||||
label="标签"
|
||||
value={draft.label}
|
||||
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, label: value }))}
|
||||
placeholder="例如:主 GPT"
|
||||
/>
|
||||
<AccountField
|
||||
label="AI 名称"
|
||||
value={draft.displayName}
|
||||
onChange={(value) =>
|
||||
updateDraft(account.accountId, (current) => ({ ...current, displayName: value }))
|
||||
}
|
||||
placeholder="例如:OpenAI 主控"
|
||||
/>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">角色</div>
|
||||
<select
|
||||
value={draft.role}
|
||||
onChange={(event) =>
|
||||
updateDraft(account.accountId, (current) => ({
|
||||
...current,
|
||||
role: event.target.value as AiAccountRole,
|
||||
}))
|
||||
}
|
||||
disabled={locked}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{roleOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">类型</div>
|
||||
<select
|
||||
value={draft.provider}
|
||||
onChange={(event) =>
|
||||
updateDraft(account.accountId, (current) => ({
|
||||
...current,
|
||||
provider: event.target.value as AiProvider,
|
||||
}))
|
||||
}
|
||||
disabled={locked}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{providerOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<AccountField
|
||||
label="账号标识"
|
||||
value={draft.accountIdentifier}
|
||||
onChange={(value) =>
|
||||
updateDraft(account.accountId, (current) => ({ ...current, accountIdentifier: value }))
|
||||
}
|
||||
placeholder="手机号 / 邮箱 / 账号别名"
|
||||
/>
|
||||
<AccountField
|
||||
label="节点标签"
|
||||
value={draft.nodeLabel}
|
||||
onChange={(value) =>
|
||||
updateDraft(account.accountId, (current) => ({ ...current, nodeLabel: value }))
|
||||
}
|
||||
placeholder="例如:Mac Studio 主控节点"
|
||||
/>
|
||||
<AccountField
|
||||
label="节点 ID"
|
||||
value={draft.nodeId}
|
||||
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, nodeId: value }))}
|
||||
placeholder="例如:mac-studio"
|
||||
/>
|
||||
<AccountField
|
||||
label="模型"
|
||||
value={draft.model}
|
||||
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, model: value }))}
|
||||
placeholder="例如:gpt-5.4"
|
||||
/>
|
||||
{draft.provider === "openai_api" ? (
|
||||
<div className="col-span-2">
|
||||
<AccountField
|
||||
label={`API Key${account.apiKeyMasked ? `(已配置 ${account.apiKeyMasked})` : ""}`}
|
||||
value={draft.apiKey}
|
||||
onChange={(value) =>
|
||||
updateDraft(account.accountId, (current) => ({ ...current, apiKey: value }))
|
||||
}
|
||||
placeholder={account.apiKeyConfigured ? "留空则保持现有 Key" : "输入 OpenAI API Key"}
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">状态备注</div>
|
||||
<textarea
|
||||
value={draft.loginStatusNote}
|
||||
onChange={(event) =>
|
||||
updateDraft(account.accountId, (current) => ({
|
||||
...current,
|
||||
loginStatusNote: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={locked}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-3 text-[13px] leading-6 text-[#111111] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
状态:{account.statusLabel}
|
||||
{account.lastValidatedAt ? ` · 校验 ${formatTimestampLabel(account.lastValidatedAt)}` : ""}
|
||||
{account.lastUsedAt ? ` · 最近使用 ${formatTimestampLabel(account.lastUsedAt)}` : ""}
|
||||
{account.lastError ? <><br />最近错误:{account.lastError}</> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void validateAccount(account.accountId)}
|
||||
disabled={busyKey === `validate:${account.accountId}`}
|
||||
className="rounded-full border border-[#D9D9D9] px-3 py-2 text-[12px] font-semibold text-[#57606A]"
|
||||
>
|
||||
{busyKey === `validate:${account.accountId}` ? "测试中" : "测试连接"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void activateAccount(account.accountId)}
|
||||
disabled={locked || busyKey === `activate:${account.accountId}`}
|
||||
className="rounded-full border border-[#D9D9D9] px-3 py-2 text-[12px] font-semibold text-[#57606A]"
|
||||
>
|
||||
{busyKey === `activate:${account.accountId}` ? "切换中" : "设为当前主控"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveAccount(account.accountId)}
|
||||
disabled={locked || busyKey === `save:${account.accountId}`}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[12px] font-semibold text-white"
|
||||
>
|
||||
{busyKey === `save:${account.accountId}` ? "保存中" : "保存"}
|
||||
</button>
|
||||
{!account.isEnvironmentFallback ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeAccount(account.accountId)}
|
||||
disabled={!canManage || busyKey === `delete:${account.accountId}`}
|
||||
className="rounded-full border border-[#FFD2D2] px-3 py-2 text-[12px] font-semibold text-[#D92D20]"
|
||||
>
|
||||
{busyKey === `delete:${account.accountId}` ? "删除中" : "删除"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">新增 AI 账号</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<AccountField
|
||||
label="标签"
|
||||
value={newDraft.label}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, label: value }))}
|
||||
placeholder="例如:API 容灾"
|
||||
/>
|
||||
<AccountField
|
||||
label="AI 名称"
|
||||
value={newDraft.displayName}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, displayName: value }))}
|
||||
placeholder="例如:OpenAI 生产主控"
|
||||
/>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">角色</div>
|
||||
<select
|
||||
value={newDraft.role}
|
||||
onChange={(event) =>
|
||||
setNewDraft((current) => ({ ...current, role: event.target.value as AiAccountRole }))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{roleOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">类型</div>
|
||||
<select
|
||||
value={newDraft.provider}
|
||||
onChange={(event) =>
|
||||
setNewDraft((current) => ({ ...current, provider: event.target.value as AiProvider }))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{providerOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<AccountField
|
||||
label="账号标识"
|
||||
value={newDraft.accountIdentifier}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, accountIdentifier: value }))}
|
||||
placeholder="手机号 / 邮箱 / 账号别名"
|
||||
/>
|
||||
<AccountField
|
||||
label="节点标签"
|
||||
value={newDraft.nodeLabel}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, nodeLabel: value }))}
|
||||
placeholder="例如:备用 Mac mini"
|
||||
/>
|
||||
<AccountField
|
||||
label="节点 ID"
|
||||
value={newDraft.nodeId}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, nodeId: value }))}
|
||||
placeholder="例如:mac-mini-02"
|
||||
/>
|
||||
<AccountField
|
||||
label="模型"
|
||||
value={newDraft.model}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, model: value }))}
|
||||
placeholder="例如:gpt-5.4"
|
||||
/>
|
||||
{newDraft.provider === "openai_api" ? (
|
||||
<div className="col-span-2">
|
||||
<AccountField
|
||||
label="API Key"
|
||||
value={newDraft.apiKey}
|
||||
onChange={(value) => setNewDraft((current) => ({ ...current, apiKey: value }))}
|
||||
placeholder="输入 OpenAI API Key"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">状态备注</div>
|
||||
<textarea
|
||||
value={newDraft.loginStatusNote}
|
||||
onChange={(event) =>
|
||||
setNewDraft((current) => ({ ...current, loginStatusNote: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-3 text-[13px] leading-6 text-[#111111] outline-none"
|
||||
placeholder="例如:该节点已单独登录 ChatGPT,等待后续接入远程执行器。"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-[13px] text-[#57606A]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newDraft.enabled}
|
||||
onChange={(event) =>
|
||||
setNewDraft((current) => ({ ...current, enabled: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveAccount()}
|
||||
disabled={!canManage || busyKey === "create:new"}
|
||||
className="rounded-full bg-[#111111] px-4 py-2 text-[12px] font-semibold text-white"
|
||||
>
|
||||
{busyKey === "create:new" ? "创建中" : "新增账号"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">最近切换记录</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{switchHistory.length ? (
|
||||
switchHistory.slice(0, 6).map((item) => (
|
||||
<div key={item.switchId} className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
{item.fromLabel ? `${item.fromLabel} -> ` : ""}
|
||||
<span className="font-semibold text-[#111111]">{item.toLabel}</span>
|
||||
<br />
|
||||
{item.reason}
|
||||
<br />
|
||||
{formatTimestampLabel(item.switchedAt)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
当前还没有新的主控切换记录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[13px] leading-6 text-[#215B39]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
src/components/app-runtime.tsx
Normal file
371
src/components/app-runtime.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import type { BossEventName } from "@/lib/boss-events";
|
||||
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections";
|
||||
import {
|
||||
clearNativeSessionSnapshot,
|
||||
currentAppLocation,
|
||||
isNativeBossApp,
|
||||
persistNativeSessionSnapshot,
|
||||
popAppHistoryEntry,
|
||||
pushAppHistoryEntry,
|
||||
readNativeSessionSnapshot,
|
||||
resolveAppBackAction,
|
||||
} from "@/lib/boss-app-client";
|
||||
|
||||
export async function sendAppLog(payload: {
|
||||
deviceId: string;
|
||||
projectId?: string;
|
||||
level: "info" | "warn" | "error";
|
||||
category: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
mirrorToMaster?: boolean;
|
||||
}) {
|
||||
try {
|
||||
await fetch("/api/v1/app-logs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore client-side log transport errors.
|
||||
}
|
||||
}
|
||||
|
||||
type AuthSessionPayload = {
|
||||
account: string;
|
||||
displayName: string;
|
||||
expiresAt: string;
|
||||
restoreToken?: string;
|
||||
};
|
||||
|
||||
function currentProjectId(pathname: string) {
|
||||
const match = pathname.match(/^\/conversations\/([^/]+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function basePathname(pathname: string) {
|
||||
return pathname.split("?")[0]?.split("#")[0] || "/";
|
||||
}
|
||||
|
||||
async function fetchNativeAwareSession() {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
cache: "no-store",
|
||||
headers: { "x-boss-native-app": "1" },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const result = (await response.json()) as { ok: boolean; session?: AuthSessionPayload };
|
||||
return result.ok ? (result.session ?? null) : null;
|
||||
}
|
||||
|
||||
async function persistSessionFromPayload(session?: AuthSessionPayload | null) {
|
||||
if (!session?.restoreToken) return;
|
||||
await persistNativeSessionSnapshot({
|
||||
restoreToken: session.restoreToken,
|
||||
account: session.account,
|
||||
displayName: session.displayName,
|
||||
expiresAt: session.expiresAt,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function AppLogBridge({
|
||||
deviceId,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
void sendAppLog({
|
||||
deviceId,
|
||||
level: "info",
|
||||
category: "app.lifecycle.ready",
|
||||
message: "APP 客户端已连接到日志桥。",
|
||||
mirrorToMaster: false,
|
||||
});
|
||||
}, [deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
void sendAppLog({
|
||||
deviceId,
|
||||
projectId: currentProjectId(pathname),
|
||||
level: "info",
|
||||
category: "navigation.route_changed",
|
||||
message: pathname,
|
||||
mirrorToMaster: false,
|
||||
});
|
||||
}, [deviceId, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
|
||||
const onError = (event: ErrorEvent) => {
|
||||
void sendAppLog({
|
||||
deviceId,
|
||||
projectId: currentProjectId(pathname),
|
||||
level: "error",
|
||||
category: "runtime.error",
|
||||
message: event.message || "未知前端错误",
|
||||
detail: event.filename ? `${event.filename}:${event.lineno}` : undefined,
|
||||
mirrorToMaster: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onRejection = (event: PromiseRejectionEvent) => {
|
||||
void sendAppLog({
|
||||
deviceId,
|
||||
projectId: currentProjectId(pathname),
|
||||
level: "error",
|
||||
category: "runtime.unhandled_rejection",
|
||||
message: "捕获到未处理 Promise 异常。",
|
||||
detail: String(event.reason ?? "unknown"),
|
||||
mirrorToMaster: true,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("error", onError);
|
||||
window.addEventListener("unhandledrejection", onRejection);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onRejection);
|
||||
};
|
||||
}, [deviceId, pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function NativeAppBridge() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const restoreInFlightRef = useRef(false);
|
||||
const currentPath = searchParams.toString() ? `${pathname}?${searchParams.toString()}` : pathname;
|
||||
|
||||
useEffect(() => {
|
||||
pushAppHistoryEntry(currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function reconcileSession() {
|
||||
const isNative = await isNativeBossApp();
|
||||
if (!isNative || restoreInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSession = await fetchNativeAwareSession().catch(() => null);
|
||||
if (cancelled) return;
|
||||
|
||||
if (activeSession?.restoreToken) {
|
||||
await persistSessionFromPayload(activeSession);
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = await readNativeSessionSnapshot();
|
||||
if (cancelled || !stored?.restoreToken) return;
|
||||
|
||||
if (Date.parse(stored.expiresAt) <= Date.now()) {
|
||||
await clearNativeSessionSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
restoreInFlightRef.current = true;
|
||||
try {
|
||||
const response = await fetch("/api/auth/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-boss-native-app": "1" },
|
||||
body: JSON.stringify({ restoreToken: stored.restoreToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await clearNativeSessionSnapshot();
|
||||
return;
|
||||
}
|
||||
const result = (await response.json()) as { ok: boolean; session?: AuthSessionPayload };
|
||||
if (!result.ok || !result.session?.restoreToken) {
|
||||
await clearNativeSessionSnapshot();
|
||||
return;
|
||||
}
|
||||
await persistSessionFromPayload(result.session);
|
||||
if (basePathname(pathname).startsWith("/auth")) {
|
||||
popAppHistoryEntry(currentPath);
|
||||
router.replace("/conversations", { scroll: false });
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} finally {
|
||||
restoreInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
void reconcileSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentPath, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
let remove: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
async function bindBackHandler() {
|
||||
if (!(await isNativeBossApp())) return;
|
||||
const { App } = await import("@capacitor/app");
|
||||
if (disposed) return;
|
||||
|
||||
const listener = await App.addListener("backButton", () => {
|
||||
const location = currentAppLocation();
|
||||
const action = resolveAppBackAction(location);
|
||||
if (action.mode === "history") {
|
||||
popAppHistoryEntry(location);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (action.mode === "replace") {
|
||||
popAppHistoryEntry(location);
|
||||
router.replace(action.target, { scroll: false });
|
||||
}
|
||||
});
|
||||
|
||||
remove = () => {
|
||||
void listener.remove();
|
||||
};
|
||||
}
|
||||
|
||||
void bindBackHandler();
|
||||
return () => {
|
||||
disposed = true;
|
||||
remove?.();
|
||||
};
|
||||
}, [router, currentPath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RealtimeRefresh({
|
||||
events,
|
||||
}: {
|
||||
events: BossEventName[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource("/api/v1/events");
|
||||
const refresh = () => router.refresh();
|
||||
const listeners = [
|
||||
"conversation.context_indicator.updated",
|
||||
"project.context_risk.updated",
|
||||
...events,
|
||||
];
|
||||
|
||||
for (const event of listeners) {
|
||||
source.addEventListener(event, refresh);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const event of listeners) {
|
||||
source.removeEventListener(event, refresh);
|
||||
}
|
||||
source.close();
|
||||
};
|
||||
}, [events, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SkillInventoryPanel({
|
||||
groups,
|
||||
boundDeviceId,
|
||||
}: {
|
||||
groups: SkillInventoryDeviceGroup[];
|
||||
boundDeviceId?: string;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function copyInvocation(skill: SkillInventoryDeviceGroup["skills"][number]) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(skill.invocation);
|
||||
setMessage(`已复制 ${skill.name} 的调用语句。`);
|
||||
} catch {
|
||||
setMessage(`复制 ${skill.name} 失败,请手动复制。`);
|
||||
return;
|
||||
}
|
||||
if (boundDeviceId) {
|
||||
void sendAppLog({
|
||||
deviceId: boundDeviceId,
|
||||
level: "info",
|
||||
category: "skill.copy_invocation",
|
||||
message: `已复制 Skill:${skill.name}`,
|
||||
detail: skill.invocation,
|
||||
mirrorToMaster: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
技能按绑定设备分类展示。每台电脑只显示自己已同步上来的 Skill,点击即可一键复制调用语句。
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.device.id} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{group.device.name}</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{group.device.account} · {group.skills.length} 个 Skill
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-1 text-[12px] font-semibold",
|
||||
group.device.id === boundDeviceId ? "bg-[#EAF7F0] text-[#215B39]" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{group.device.id === boundDeviceId ? "当前绑定设备" : "已同步设备"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{group.skills.map((skill) => (
|
||||
<div key={skill.skillId} className="rounded-2xl bg-[#F7F8FA] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-[#111111]">{skill.name}</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">{skill.path}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyInvocation(skill)}
|
||||
className="rounded-full bg-[#07C160] px-3 py-2 text-[12px] font-semibold text-white"
|
||||
>
|
||||
复制调用
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">{skill.description}</div>
|
||||
<div className="mt-2 text-[12px] text-[#8C8C8C]">调用语句:{skill.invocation}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] text-[#215B39]">{message}</div>
|
||||
) : null}
|
||||
{!groups.length ? (
|
||||
<div className="rounded-2xl bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#D46B08]">
|
||||
当前还没有同步到任何 Skill。请先保证本机 local-agent 在线,并已扫描到 `~/.codex/skills`。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1876
src/components/app-ui.tsx
Normal file
1876
src/components/app-ui.tsx
Normal file
File diff suppressed because it is too large
Load Diff
152
src/lib/boss-app-client.ts
Normal file
152
src/lib/boss-app-client.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
const APP_HISTORY_KEY = "boss.app.history.v1";
|
||||
const NATIVE_SESSION_KEY = "boss.native.session.v1";
|
||||
const MAX_HISTORY_ENTRIES = 48;
|
||||
|
||||
export type NativeSessionSnapshot = {
|
||||
restoreToken: string;
|
||||
account: string;
|
||||
displayName: string;
|
||||
expiresAt: string;
|
||||
lastSyncedAt: string;
|
||||
};
|
||||
|
||||
export type AppBackAction =
|
||||
| { mode: "history" }
|
||||
| { mode: "replace"; target: string }
|
||||
| { mode: "noop" };
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function readJsonStorage<T>(key: string, storage: Storage): T | null {
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonStorage(key: string, value: unknown, storage: Storage) {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Ignore storage write failures in constrained webviews.
|
||||
}
|
||||
}
|
||||
|
||||
function basePathFor(input: string) {
|
||||
return input.split("#")[0]?.split("?")[0] || "/";
|
||||
}
|
||||
|
||||
function fallbackForPath(input: string) {
|
||||
const pathname = basePathFor(input);
|
||||
if (pathname === "/auth/login") return null;
|
||||
if (pathname.startsWith("/auth/")) return "/auth/login";
|
||||
if (pathname === "/conversations") return null;
|
||||
if (pathname.startsWith("/conversations/")) return "/conversations";
|
||||
if (pathname === "/devices") {
|
||||
return input.includes("?") ? "/devices" : "/conversations";
|
||||
}
|
||||
if (pathname.startsWith("/devices/")) return "/devices";
|
||||
if (pathname === "/me") return "/conversations";
|
||||
if (pathname.startsWith("/me/")) return "/me";
|
||||
return "/conversations";
|
||||
}
|
||||
|
||||
export function currentAppLocation() {
|
||||
if (!isBrowser()) return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export function pushAppHistoryEntry(path: string) {
|
||||
if (!isBrowser()) return;
|
||||
const current = readJsonStorage<string[]>(APP_HISTORY_KEY, window.sessionStorage) ?? [];
|
||||
const normalized = path || "/";
|
||||
const next = current.filter((entry) => entry !== normalized);
|
||||
next.push(normalized);
|
||||
writeJsonStorage(APP_HISTORY_KEY, next.slice(-MAX_HISTORY_ENTRIES), window.sessionStorage);
|
||||
}
|
||||
|
||||
export function popAppHistoryEntry(expectedPath?: string) {
|
||||
if (!isBrowser()) return;
|
||||
const current = readJsonStorage<string[]>(APP_HISTORY_KEY, window.sessionStorage) ?? [];
|
||||
if (!current.length) return;
|
||||
if (expectedPath && current[current.length - 1] !== expectedPath) {
|
||||
writeJsonStorage(
|
||||
APP_HISTORY_KEY,
|
||||
current.filter((entry) => entry !== expectedPath),
|
||||
window.sessionStorage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
current.pop();
|
||||
writeJsonStorage(APP_HISTORY_KEY, current, window.sessionStorage);
|
||||
}
|
||||
|
||||
export function resolveAppBackAction(currentPath: string, explicitFallback?: string): AppBackAction {
|
||||
if (!isBrowser()) {
|
||||
return explicitFallback ? { mode: "replace", target: explicitFallback } : { mode: "noop" };
|
||||
}
|
||||
|
||||
const history = readJsonStorage<string[]>(APP_HISTORY_KEY, window.sessionStorage) ?? [];
|
||||
const previous = [...history].reverse().find((entry) => entry !== currentPath);
|
||||
if (previous) {
|
||||
return { mode: "history" };
|
||||
}
|
||||
|
||||
const fallback = explicitFallback ?? fallbackForPath(currentPath);
|
||||
if (fallback && fallback !== currentPath) {
|
||||
return { mode: "replace", target: fallback };
|
||||
}
|
||||
|
||||
return { mode: "noop" };
|
||||
}
|
||||
|
||||
export async function isNativeBossApp() {
|
||||
if (!isBrowser()) return false;
|
||||
const { Capacitor } = await import("@capacitor/core");
|
||||
return Capacitor.getPlatform() !== "web";
|
||||
}
|
||||
|
||||
async function preferencesApi() {
|
||||
if (!(await isNativeBossApp())) return null;
|
||||
const { Preferences } = await import("@capacitor/preferences");
|
||||
return Preferences;
|
||||
}
|
||||
|
||||
export async function persistNativeSessionSnapshot(snapshot: NativeSessionSnapshot) {
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const preferences = await preferencesApi();
|
||||
if (preferences) {
|
||||
await preferences.set({ key: NATIVE_SESSION_KEY, value: serialized });
|
||||
return;
|
||||
}
|
||||
if (isBrowser()) {
|
||||
window.localStorage.setItem(NATIVE_SESSION_KEY, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNativeSessionSnapshot() {
|
||||
const preferences = await preferencesApi();
|
||||
if (preferences) {
|
||||
const result = await preferences.get({ key: NATIVE_SESSION_KEY });
|
||||
return result.value ? (JSON.parse(result.value) as NativeSessionSnapshot) : null;
|
||||
}
|
||||
if (!isBrowser()) return null;
|
||||
return readJsonStorage<NativeSessionSnapshot>(NATIVE_SESSION_KEY, window.localStorage);
|
||||
}
|
||||
|
||||
export async function clearNativeSessionSnapshot() {
|
||||
const preferences = await preferencesApi();
|
||||
if (preferences) {
|
||||
await preferences.remove({ key: NATIVE_SESSION_KEY });
|
||||
return;
|
||||
}
|
||||
if (isBrowser()) {
|
||||
window.localStorage.removeItem(NATIVE_SESSION_KEY);
|
||||
}
|
||||
}
|
||||
73
src/lib/boss-auth.ts
Normal file
73
src/lib/boss-auth.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession } from "@/lib/boss-data";
|
||||
|
||||
export const AUTH_SESSION_COOKIE = "boss_session";
|
||||
|
||||
function shouldUseSecureCookie(request?: NextRequest) {
|
||||
const forwardedProto = request?.headers.get("x-forwarded-proto");
|
||||
return request?.nextUrl.protocol === "https:" || forwardedProto === "https";
|
||||
}
|
||||
|
||||
export async function getCurrentPageSession() {
|
||||
const token = (await cookies()).get(AUTH_SESSION_COOKIE)?.value;
|
||||
return getAuthSession(token);
|
||||
}
|
||||
|
||||
export async function requirePageSession() {
|
||||
const session = await getCurrentPageSession();
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function redirectIfAuthenticated() {
|
||||
const session = await getCurrentPageSession();
|
||||
if (session) {
|
||||
redirect("/conversations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequestSession(request: NextRequest) {
|
||||
return getAuthSession(request.cookies.get(AUTH_SESSION_COOKIE)?.value);
|
||||
}
|
||||
|
||||
export async function requireRequestSession(request: NextRequest) {
|
||||
const session = await getRequestSession(request);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function setAuthSessionCookie(
|
||||
response: NextResponse,
|
||||
sessionToken: string,
|
||||
request?: NextRequest,
|
||||
) {
|
||||
response.cookies.set({
|
||||
name: AUTH_SESSION_COOKIE,
|
||||
value: sessionToken,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: shouldUseSecureCookie(request),
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function clearAuthSessionCookie(response: NextResponse, request?: NextRequest) {
|
||||
response.cookies.set({
|
||||
name: AUTH_SESSION_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: shouldUseSecureCookie(request),
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
4102
src/lib/boss-data.ts
Normal file
4102
src/lib/boss-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
34
src/lib/boss-device-auth.ts
Normal file
34
src/lib/boss-device-auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
export async function authorizeDeviceWriteRequest(
|
||||
request: NextRequest,
|
||||
deviceId: string,
|
||||
) {
|
||||
const device = await getDevice(deviceId);
|
||||
const session = await requireRequestSession(request);
|
||||
|
||||
if (device && session && (session.role === "highest_admin" || device.account === session.account)) {
|
||||
return {
|
||||
ok: true as const,
|
||||
device,
|
||||
principal: "session" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const deviceToken = request.headers.get("x-boss-device-token") ?? undefined;
|
||||
if (deviceToken && (await verifyDeviceToken(deviceId, deviceToken))) {
|
||||
return {
|
||||
ok: true as const,
|
||||
device,
|
||||
principal: "device_token" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
device,
|
||||
principal: null,
|
||||
};
|
||||
}
|
||||
38
src/lib/boss-events.ts
Normal file
38
src/lib/boss-events.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export type BossEventName =
|
||||
| "conversation.updated"
|
||||
| "project.messages.updated"
|
||||
| "project.context_risk.updated"
|
||||
| "app.logs.updated"
|
||||
| "master_agent.task.updated"
|
||||
| "devices.updated"
|
||||
| "devices.skills.updated"
|
||||
| "ota.updated";
|
||||
|
||||
export interface BossEventPayload {
|
||||
at: string;
|
||||
projectId?: string;
|
||||
deviceId?: string;
|
||||
taskId?: string;
|
||||
status?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
export function publishBossEvent(event: BossEventName, payload: Omit<BossEventPayload, "at"> = {}) {
|
||||
eventBus.emit("boss-event", event, {
|
||||
at: new Date().toISOString(),
|
||||
...payload,
|
||||
} satisfies BossEventPayload);
|
||||
}
|
||||
|
||||
export function subscribeBossEvents(listener: BossEventListener) {
|
||||
eventBus.on("boss-event", listener);
|
||||
return () => {
|
||||
eventBus.off("boss-event", listener);
|
||||
};
|
||||
}
|
||||
171
src/lib/boss-mail.ts
Normal file
171
src/lib/boss-mail.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type VerificationDeliveryMode = "fixed" | "email";
|
||||
export type VerificationPurpose = "login" | "register" | "forgot-password";
|
||||
|
||||
export interface VerificationDeliveryResult {
|
||||
delivered: boolean;
|
||||
mode: VerificationDeliveryMode;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
function purposeLabel(purpose: VerificationPurpose) {
|
||||
switch (purpose) {
|
||||
case "login":
|
||||
return "登录";
|
||||
case "register":
|
||||
return "注册";
|
||||
case "forgot-password":
|
||||
return "重置密码";
|
||||
default:
|
||||
return "验证";
|
||||
}
|
||||
}
|
||||
|
||||
export function getVerificationDeliveryMode(): VerificationDeliveryMode {
|
||||
return process.env.BOSS_AUTH_VERIFICATION_MODE === "email" ? "email" : "fixed";
|
||||
}
|
||||
|
||||
export function getFixedVerificationCode() {
|
||||
return process.env.BOSS_AUTH_FIXED_CODE?.trim() || "000000";
|
||||
}
|
||||
|
||||
export function getVerificationDeliverySummary(code: string) {
|
||||
if (getVerificationDeliveryMode() === "email") {
|
||||
return "验证码邮件已发送,请检查邮箱。";
|
||||
}
|
||||
return `邮件验证码尚未切到真实投递,当前固定验证码为 ${code}。`;
|
||||
}
|
||||
|
||||
function isLikelyEmailAccount(account: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account);
|
||||
}
|
||||
|
||||
function buildVerificationMessage({
|
||||
account,
|
||||
recipient,
|
||||
purpose,
|
||||
code,
|
||||
}: {
|
||||
account: string;
|
||||
recipient: string;
|
||||
purpose: VerificationPurpose;
|
||||
code: string;
|
||||
}) {
|
||||
const domain = process.env.BOSS_MAIL_DOMAIN?.trim() || "boss.hyzq.net";
|
||||
const fromAddress = process.env.BOSS_MAIL_FROM_ADDRESS?.trim() || `verify@${domain}`;
|
||||
const fromName = process.env.BOSS_MAIL_FROM_NAME?.trim() || "Boss Verify";
|
||||
const subject = `Boss ${purposeLabel(purpose)}验证码`;
|
||||
const messageId = `<${Date.now()}.${randomBytes(4).toString("hex")}@${domain}>`;
|
||||
const body = [
|
||||
`你好,${account}`,
|
||||
"",
|
||||
recipient !== account ? `当前验证码投递到绑定邮箱:${recipient}` : undefined,
|
||||
`你本次的 Boss ${purposeLabel(purpose)}验证码是:${code}`,
|
||||
"验证码 5 分钟内有效。",
|
||||
"",
|
||||
"如果这不是你本人发起的操作,请忽略本邮件。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const lines = [
|
||||
`From: ${fromName} <${fromAddress}>`,
|
||||
`To: ${recipient}`,
|
||||
`Subject: ${subject}`,
|
||||
`Date: ${new Date().toUTCString()}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function sendMail(message: string) {
|
||||
const sendmailPath = process.env.BOSS_SENDMAIL_PATH?.trim() || "/usr/sbin/sendmail";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(sendmailPath, ["-t", "-i"], { stdio: ["pipe", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
|
||||
child.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `SENDMAIL_EXIT_${code ?? "UNKNOWN"}`));
|
||||
});
|
||||
|
||||
child.stdin.end(message);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deliverVerificationCode({
|
||||
account,
|
||||
recipient,
|
||||
purpose,
|
||||
code,
|
||||
}: {
|
||||
account: string;
|
||||
recipient?: string | null;
|
||||
purpose: VerificationPurpose;
|
||||
code: string;
|
||||
}): Promise<VerificationDeliveryResult> {
|
||||
const mode = getVerificationDeliveryMode();
|
||||
if (mode === "fixed") {
|
||||
return {
|
||||
delivered: true,
|
||||
mode,
|
||||
status: 200,
|
||||
message: getVerificationDeliverySummary(code),
|
||||
};
|
||||
}
|
||||
|
||||
const finalRecipient = recipient?.trim() || account;
|
||||
if (!isLikelyEmailAccount(finalRecipient)) {
|
||||
return {
|
||||
delivered: false,
|
||||
mode,
|
||||
status: 400,
|
||||
message: "当前邮件验证码模式需要可接收验证码的邮箱账号或已绑定验证邮箱。",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMail(
|
||||
buildVerificationMessage({
|
||||
account,
|
||||
recipient: finalRecipient,
|
||||
purpose,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
delivered: true,
|
||||
mode,
|
||||
status: 200,
|
||||
message: getVerificationDeliverySummary(code),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "EMAIL_DELIVERY_FAILED";
|
||||
return {
|
||||
delivered: false,
|
||||
mode,
|
||||
status: 502,
|
||||
message: `验证码邮件发送失败:${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
390
src/lib/boss-master-agent.ts
Normal file
390
src/lib/boss-master-agent.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
aiProviderLabel,
|
||||
appendProjectMessage,
|
||||
getRuntimeAiAccountById,
|
||||
getMasterAgentRuntimeAccount,
|
||||
getMasterAgentTask,
|
||||
queueMasterAgentTask,
|
||||
readState,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
function buildMasterAgentInstructions() {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
"你要基于当前运行时状态给出中文回复,要求直接、可执行、便于继续联调。",
|
||||
"优先关注线程上下文预算、must_finish_before_compaction、最新 APP 日志、设备在线状态和 OTA 状态。",
|
||||
"如果信息不足,就明确说缺什么;不要编造设备状态或执行结果。",
|
||||
"如果用户要继续开发,默认给出下一步实现/验证动作,而不是泛泛安慰。",
|
||||
"保持回答简洁,通常 3-6 句即可。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRuntimeDigest(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
) {
|
||||
const recentMessages = state.projects
|
||||
.find((project) => project.id === "master-agent")
|
||||
?.messages.slice(-6)
|
||||
.map((message) => `${message.senderLabel}:${message.body}`)
|
||||
.join("\n");
|
||||
|
||||
const recentLogs = state.appLogs
|
||||
.slice(0, 5)
|
||||
.map((log) => `${log.createdAt} ${log.deviceId} ${log.category} ${log.message}`)
|
||||
.join("\n");
|
||||
|
||||
const riskyThreads = state.threadContextSnapshots
|
||||
.slice()
|
||||
.sort((a, b) => a.contextBudgetRemainingPct - b.contextBudgetRemainingPct)
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(snapshot) =>
|
||||
`${snapshot.projectId} / ${snapshot.title} / ${snapshot.contextBudgetLevel} / ${snapshot.contextBudgetRemainingPct}% / must_finish=${snapshot.mustFinishBeforeCompaction ? "yes" : "no"} / ${snapshot.summary}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const devices = state.devices
|
||||
.map(
|
||||
(device) =>
|
||||
`${device.name}(${device.id}) 状态=${device.status} 账号=${device.account} 5h=${device.quota5h} 7d=${device.quota7d}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const ota = state.otaUpdates
|
||||
.filter((update) => update.status === "available")
|
||||
.map((update) => `${update.version} -> ${update.targetScope}`)
|
||||
.join("\n");
|
||||
|
||||
const authSummary = [
|
||||
`登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`,
|
||||
"Cookie Max-Age:2592000 秒。",
|
||||
currentSessionExpiresAt ? `当前请求会话到期时间:${currentSessionExpiresAt}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`当前时间:${new Date().toISOString()}`,
|
||||
`用户消息:${requestText}`,
|
||||
"",
|
||||
"最近主 Agent 对话:",
|
||||
recentMessages || "无",
|
||||
"",
|
||||
"最新 APP 日志:",
|
||||
recentLogs || "无",
|
||||
"",
|
||||
"高风险线程:",
|
||||
riskyThreads || "无",
|
||||
"",
|
||||
"在线设备:",
|
||||
devices || "无",
|
||||
"",
|
||||
"认证状态:",
|
||||
authSummary,
|
||||
"",
|
||||
"可用 OTA:",
|
||||
ota || "无",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function extractResponseText(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const response = payload as {
|
||||
output_text?: string;
|
||||
output?: Array<{
|
||||
content?: Array<{ type?: string; text?: string; content?: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (typeof response.output_text === "string" && response.output_text.trim()) {
|
||||
return response.output_text.trim();
|
||||
}
|
||||
|
||||
const chunks =
|
||||
response.output
|
||||
?.flatMap((item) => item.content ?? [])
|
||||
.map((item) => {
|
||||
if (typeof item.text === "string") return item.text;
|
||||
if (typeof item.content === "string") return item.content;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
return chunks.join("\n").trim();
|
||||
}
|
||||
|
||||
function normalizeOpenAiError(message: string) {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return "主 Agent 当前调用模型失败。";
|
||||
if (trimmed.length <= 240) return trimmed;
|
||||
return `${trimmed.slice(0, 237)}...`;
|
||||
}
|
||||
|
||||
async function generateOpenAiReply(params: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
reasoning: { effort: "medium" },
|
||||
instructions: buildMasterAgentInstructions(),
|
||||
input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt),
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
|
||||
const requestId = response.headers.get("x-request-id") ?? undefined;
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { error?: { message?: string } }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError =
|
||||
payload && typeof payload === "object" && "error" in payload
|
||||
? payload.error?.message
|
||||
: undefined;
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
`${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const content = extractResponseText(payload);
|
||||
if (!content) {
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
`模型已返回成功状态,但没有可用文本输出${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent") {
|
||||
return appendProjectMessage({
|
||||
projectId: "master-agent",
|
||||
sender: "master",
|
||||
senderLabel,
|
||||
body,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
|
||||
function buildMasterCodexNodePrompt(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
) {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent,运行在用户自己的 Master Codex Node 上。",
|
||||
"请结合下面的运行时状态和用户消息,直接给出中文回复。",
|
||||
"如果你认为需要继续在当前仓库里推进实现、排障或验证,可以直接说明你下一步会做什么;如果必须先做交接或收尾,也要明确说出原因。",
|
||||
"保持简洁,优先给出结论、动作、验证点。",
|
||||
"",
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (task?.status === "completed" || task?.status === "failed") {
|
||||
return task;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
||||
}
|
||||
return getMasterAgentTask(taskId);
|
||||
}
|
||||
|
||||
export async function validateAiAccountConnection(accountId: string) {
|
||||
const account = await getRuntimeAiAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error("AI_ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (account.provider === "master_codex_node") {
|
||||
return {
|
||||
ok: Boolean(account.nodeId?.trim()) as boolean,
|
||||
status: account.nodeId?.trim() ? "ready" : "needs_login",
|
||||
message:
|
||||
account.nodeId?.trim()
|
||||
? "Master Codex Node 已配置。主 Agent 会通过 local-agent relay 把任务转交给该节点上的 Codex。"
|
||||
: "请先填写 Master Codex Node 的节点 ID,再让 local-agent 认领主 Agent 任务。",
|
||||
};
|
||||
}
|
||||
|
||||
if (account.provider !== "openai_api" || !account.apiKey?.trim()) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: "needs_api_key",
|
||||
message: "当前账号还没有可用的 OpenAI API Key。",
|
||||
};
|
||||
}
|
||||
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: account.apiKey,
|
||||
model: account.model || "gpt-5.4",
|
||||
requestText: "请只回复“连接正常”。",
|
||||
});
|
||||
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "ready",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
status: "ready",
|
||||
message: generated.content,
|
||||
requestId: generated.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function replyToMasterAgentUserMessage(params: {
|
||||
requestMessageId?: string;
|
||||
requestText: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
}) {
|
||||
const runtime = await getMasterAgentRuntimeAccount();
|
||||
|
||||
if (!runtime?.account) {
|
||||
await appendMasterAgentSystemReply(
|
||||
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API 账号,再继续对话。",
|
||||
);
|
||||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||||
}
|
||||
|
||||
if (runtime.account.provider === "master_codex_node") {
|
||||
const state = await readState();
|
||||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||||
const task = await queueMasterAgentTask({
|
||||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||||
requestText: params.requestText,
|
||||
executionPrompt: buildMasterCodexNodePrompt(
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId,
|
||||
accountId: runtime.account.accountId,
|
||||
accountLabel: runtime.summary.roleLabel,
|
||||
});
|
||||
const completedTask = await waitForMasterAgentTaskCompletion(task.taskId);
|
||||
if (completedTask?.status === "completed") {
|
||||
return {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
taskId: task.taskId,
|
||||
requestId: completedTask.requestId,
|
||||
};
|
||||
}
|
||||
if (completedTask?.status === "failed") {
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: "MASTER_NODE_EXEC_FAILED",
|
||||
taskId: task.taskId,
|
||||
message: completedTask.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${runtime.account.nodeLabel ?? deviceId} 的 Master Codex Node。`,
|
||||
"如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: true as const, accountId: runtime.account.accountId, taskId: task.taskId };
|
||||
}
|
||||
|
||||
if (runtime.account.provider !== "openai_api" || !runtime.account.apiKey?.trim()) {
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},来源 ${aiProviderLabel(runtime.account.provider)}。`,
|
||||
"当前账号既没有接入 Master Codex Node 执行器,也没有可用的 OpenAI API Key。",
|
||||
"请到“我的 > AI 账号”补一个可用的 OpenAI API 账号,或者把当前节点接回 Master Codex Node relay。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: runtime.account.apiKey,
|
||||
model: runtime.account.model || "gpt-5.4",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
});
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
generated.content,
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
|
||||
if (!runtime.isEnvironmentFallback) {
|
||||
await updateAiAccountHealth({
|
||||
accountId: runtime.account.accountId,
|
||||
status: "ready",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
activate: !runtime.account.isActive,
|
||||
switchReason: runtime.account.isActive
|
||||
? runtime.account.switchReason
|
||||
: `主 Agent 回复时自动切换到 ${runtime.account.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
requestId: generated.requestId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "主 Agent 当前调用模型失败。";
|
||||
if (!runtime.isEnvironmentFallback) {
|
||||
await updateAiAccountHealth({
|
||||
accountId: runtime.account.accountId,
|
||||
status: "degraded",
|
||||
lastError: message,
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`我已经收到你的消息,但当前 AI 账号调用失败:${message}。`,
|
||||
"请到“我的 > AI 账号”检查 API Key、模型名或切换到其他 AI 账号后重试。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MODEL_CALL_FAILED", message };
|
||||
}
|
||||
}
|
||||
92
src/lib/boss-ota.ts
Normal file
92
src/lib/boss-ota.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PublishedOtaAsset {
|
||||
absolutePath: string;
|
||||
fileName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
updatedAt: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
const OTA_PACKAGE_FILE_NAME = "boss-android-latest.apk";
|
||||
const OTA_META_FILE_NAME = "boss-android-latest.json";
|
||||
const OTA_DOWNLOAD_URL = "/api/v1/user/ota/package";
|
||||
|
||||
function detectRuntimeRoot(startDir: string) {
|
||||
let current = startDir;
|
||||
while (true) {
|
||||
if (existsSync(path.join(current, "package.json")) && existsSync(path.join(current, "src", "app"))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return startDir;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeRoot() {
|
||||
if (process.env.BOSS_RUNTIME_ROOT?.trim()) {
|
||||
return path.resolve(process.env.BOSS_RUNTIME_ROOT);
|
||||
}
|
||||
if (process.env.BOSS_STATE_FILE?.trim()) {
|
||||
return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE)));
|
||||
}
|
||||
return detectRuntimeRoot(/* turbopackIgnore: true */ process.cwd());
|
||||
}
|
||||
|
||||
const runtimeRoot = resolveRuntimeRoot();
|
||||
|
||||
function otaPublicDir() {
|
||||
return path.join(runtimeRoot, "public", "downloads");
|
||||
}
|
||||
|
||||
function otaPackagePath() {
|
||||
return path.join(otaPublicDir(), OTA_PACKAGE_FILE_NAME);
|
||||
}
|
||||
|
||||
function otaMetaPath() {
|
||||
return path.join(otaPublicDir(), OTA_META_FILE_NAME);
|
||||
}
|
||||
|
||||
export async function getPublishedOtaAsset(): Promise<PublishedOtaAsset | null> {
|
||||
const apkPath = otaPackagePath();
|
||||
if (!existsSync(apkPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metaPath = otaMetaPath();
|
||||
if (existsSync(metaPath)) {
|
||||
try {
|
||||
const meta = JSON.parse(await fs.readFile(metaPath, "utf8")) as Partial<PublishedOtaAsset> & {
|
||||
urlPath?: string;
|
||||
};
|
||||
return {
|
||||
absolutePath: apkPath,
|
||||
fileName: meta.fileName ?? OTA_PACKAGE_FILE_NAME,
|
||||
sizeBytes: meta.sizeBytes ?? (await fs.stat(apkPath)).size,
|
||||
sha256: meta.sha256 ?? createHash("sha256").update(await fs.readFile(apkPath)).digest("hex"),
|
||||
updatedAt: meta.updatedAt ?? (await fs.stat(apkPath)).mtime.toISOString(),
|
||||
downloadUrl: meta.downloadUrl ?? meta.urlPath ?? OTA_DOWNLOAD_URL,
|
||||
};
|
||||
} catch {
|
||||
// Fall through to live stat/hash.
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await fs.stat(apkPath);
|
||||
const content = await fs.readFile(apkPath);
|
||||
return {
|
||||
absolutePath: apkPath,
|
||||
fileName: OTA_PACKAGE_FILE_NAME,
|
||||
sizeBytes: stat.size,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
downloadUrl: OTA_DOWNLOAD_URL,
|
||||
};
|
||||
}
|
||||
480
src/lib/boss-projections.ts
Normal file
480
src/lib/boss-projections.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import type {
|
||||
AiAccountRole,
|
||||
AiProvider,
|
||||
AiAccountStatus,
|
||||
AppLogEntry,
|
||||
AuditTaskRequest,
|
||||
AuditTaskResult,
|
||||
BossState,
|
||||
Capability,
|
||||
ContextBudgetLevel,
|
||||
Device,
|
||||
DeviceEnrollment,
|
||||
DeviceSkill,
|
||||
MasterIdentitySummary,
|
||||
OpsFault,
|
||||
OpsRepairTicket,
|
||||
OpsRepairVerification,
|
||||
Project,
|
||||
RiskLevel,
|
||||
ThreadContextAlert,
|
||||
ThreadContextSnapshot,
|
||||
ThreadHandoffPackage,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
export interface ContextIndicator {
|
||||
visible: boolean;
|
||||
style: "ring_percent";
|
||||
percent?: number;
|
||||
level?: ContextBudgetLevel;
|
||||
}
|
||||
|
||||
export interface ConversationItem {
|
||||
conversationId: string;
|
||||
conversationType: "master_agent" | "single_device" | "group";
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
preview: string;
|
||||
manualPinned: boolean;
|
||||
latestReplyAt: string;
|
||||
latestReplyLabel: string;
|
||||
unreadCount: number;
|
||||
riskLevel: RiskLevel;
|
||||
activeDeviceCount: number;
|
||||
deviceNamesPreview: string[];
|
||||
avatar: {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
overflowCount?: number;
|
||||
};
|
||||
contextBudgetIndicator: ContextIndicator;
|
||||
contextBudgetSourceNodeId?: string;
|
||||
contextBudgetUpdatedAt?: string;
|
||||
mustFinishBeforeCompaction: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadContextView {
|
||||
snapshot: ThreadContextSnapshot;
|
||||
handoffPackage?: ThreadHandoffPackage;
|
||||
alerts: ThreadContextAlert[];
|
||||
}
|
||||
|
||||
export interface ProjectDetailView {
|
||||
project: Project;
|
||||
devices: Device[];
|
||||
masterIdentity?: MasterIdentitySummary;
|
||||
activeThreadContexts: ThreadContextView[];
|
||||
nextCompactionRiskThreadId?: string;
|
||||
threadsRequiringHandoff: ThreadContextView[];
|
||||
masterContextStrategySummary: string;
|
||||
recentAppLogs: AppLogEntry[];
|
||||
openFaults: OpsFault[];
|
||||
relatedAuditResults: AuditTaskResult[];
|
||||
}
|
||||
|
||||
export interface ThreadContextDetailView {
|
||||
snapshot: ThreadContextSnapshot;
|
||||
handoffPackage?: ThreadHandoffPackage;
|
||||
alerts: ThreadContextAlert[];
|
||||
currentChecklist: string[];
|
||||
masterActions: string[];
|
||||
}
|
||||
|
||||
export interface DeviceWorkspaceView {
|
||||
selectedDevice?: Device;
|
||||
relatedThreads: ThreadContextSnapshot[];
|
||||
activeEnrollment?: DeviceEnrollment;
|
||||
}
|
||||
|
||||
export interface OpsSummaryView {
|
||||
mode: "active" | "idle";
|
||||
faults: OpsFault[];
|
||||
tickets: Array<
|
||||
OpsRepairTicket & {
|
||||
verification?: OpsRepairVerification;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AuditSummaryView {
|
||||
pendingRequests: AuditTaskRequest[];
|
||||
latestResults: AuditTaskResult[];
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
export interface SkillInventoryDeviceGroup {
|
||||
device: Device;
|
||||
skills: DeviceSkill[];
|
||||
}
|
||||
|
||||
export interface SkillInventoryView {
|
||||
boundDeviceId?: string;
|
||||
groups: SkillInventoryDeviceGroup[];
|
||||
}
|
||||
|
||||
const levelPriority: Record<ContextBudgetLevel, number> = {
|
||||
critical: 0,
|
||||
urgent: 1,
|
||||
watch: 2,
|
||||
safe: 3,
|
||||
};
|
||||
|
||||
const aiRolePriority: Record<AiAccountRole, number> = {
|
||||
primary: 0,
|
||||
backup: 1,
|
||||
api_fallback: 2,
|
||||
};
|
||||
|
||||
const shanghaiFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const shanghaiDayFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export function formatTimestampLabel(value?: string, fallback = "刚刚") {
|
||||
if (!value) return fallback;
|
||||
if (!value.includes("T")) return value;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
if (Math.abs(diff) < 60_000) return "刚刚";
|
||||
if (diff >= 0 && diff < 24 * 60 * 60_000) {
|
||||
return shanghaiFormatter.format(date);
|
||||
}
|
||||
return shanghaiDayFormatter.format(date);
|
||||
}
|
||||
|
||||
function compareSnapshots(a: ThreadContextSnapshot, b: ThreadContextSnapshot) {
|
||||
if (a.mustFinishBeforeCompaction !== b.mustFinishBeforeCompaction) {
|
||||
return a.mustFinishBeforeCompaction ? -1 : 1;
|
||||
}
|
||||
if (levelPriority[a.contextBudgetLevel] !== levelPriority[b.contextBudgetLevel]) {
|
||||
return levelPriority[a.contextBudgetLevel] - levelPriority[b.contextBudgetLevel];
|
||||
}
|
||||
return (a.compactionExpectedAt ?? "").localeCompare(b.compactionExpectedAt ?? "");
|
||||
}
|
||||
|
||||
function projectType(project: Project): ConversationItem["conversationType"] {
|
||||
if (project.id === "master-agent") return "master_agent";
|
||||
return project.isGroup ? "group" : "single_device";
|
||||
}
|
||||
|
||||
function aiRoleLabel(role: AiAccountRole) {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
return "主 GPT";
|
||||
case "backup":
|
||||
return "备用 GPT";
|
||||
case "api_fallback":
|
||||
return "API 容灾";
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
function aiProviderLabel(provider: AiProvider) {
|
||||
switch (provider) {
|
||||
case "master_codex_node":
|
||||
return "Master Codex Node";
|
||||
case "openai_api":
|
||||
return "OpenAI API";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
function aiStatusLabel(status: AiAccountStatus) {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return "可用";
|
||||
case "needs_login":
|
||||
return "待登录";
|
||||
case "needs_api_key":
|
||||
return "待配置 Key";
|
||||
case "degraded":
|
||||
return "异常";
|
||||
case "disabled":
|
||||
return "已停用";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function canGenerateAiAccount(account: BossState["aiAccounts"][number]) {
|
||||
if (!account.enabled) return false;
|
||||
if (account.provider === "master_codex_node") {
|
||||
return Boolean(account.nodeId?.trim());
|
||||
}
|
||||
return Boolean(account.apiKey?.trim());
|
||||
}
|
||||
|
||||
function getProjectMasterIdentity(state: BossState): MasterIdentitySummary {
|
||||
const accounts = [...state.aiAccounts].sort((a, b) => {
|
||||
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
||||
if (aiRolePriority[a.role] !== aiRolePriority[b.role]) {
|
||||
return aiRolePriority[a.role] - aiRolePriority[b.role];
|
||||
}
|
||||
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||||
});
|
||||
const account = accounts.find((item) => item.isActive) ?? accounts.find(canGenerateAiAccount) ?? accounts[0];
|
||||
|
||||
if (!account) {
|
||||
return {
|
||||
label: "API 容灾",
|
||||
role: "api_fallback",
|
||||
roleLabel: aiRoleLabel("api_fallback"),
|
||||
provider: "openai_api",
|
||||
providerLabel: aiProviderLabel("openai_api"),
|
||||
displayName: "未配置 AI 账号",
|
||||
status: "needs_api_key",
|
||||
statusLabel: aiStatusLabel("needs_api_key"),
|
||||
canGenerate: false,
|
||||
note: "请到“我的 > AI 账号”补齐主控 AI 账号。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
label: account.label,
|
||||
role: account.role,
|
||||
roleLabel: aiRoleLabel(account.role),
|
||||
provider: account.provider,
|
||||
providerLabel: aiProviderLabel(account.provider),
|
||||
displayName: account.displayName,
|
||||
nodeLabel: account.nodeLabel,
|
||||
model: account.model,
|
||||
status: account.status,
|
||||
statusLabel: aiStatusLabel(account.status),
|
||||
canGenerate: canGenerateAiAccount(account),
|
||||
switchReason: account.switchReason,
|
||||
lastSwitchedAt: account.lastSwitchedAt,
|
||||
note: account.loginStatusNote,
|
||||
};
|
||||
}
|
||||
|
||||
function threadViewsForProject(state: BossState, projectId: string) {
|
||||
return state.threadContextSnapshots
|
||||
.filter((snapshot) => snapshot.projectId === projectId)
|
||||
.sort(compareSnapshots)
|
||||
.map((snapshot) => ({
|
||||
snapshot,
|
||||
handoffPackage: state.threadHandoffPackages.find(
|
||||
(item) => item.fromThreadId === snapshot.threadId && item.packageStatus !== "expired",
|
||||
),
|
||||
alerts: state.threadContextAlerts.filter((alert) => alert.threadId === snapshot.threadId),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
const conversations = state.projects.map((project) => {
|
||||
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
|
||||
const threadViews = threadViewsForProject(state, project.id);
|
||||
const topThread = threadViews[0]?.snapshot;
|
||||
|
||||
return {
|
||||
conversationId: `conv-${project.id}`,
|
||||
conversationType: projectType(project),
|
||||
projectId: project.id,
|
||||
projectTitle: project.name,
|
||||
preview: project.preview,
|
||||
manualPinned: Boolean(project.pinned && !project.systemPinned),
|
||||
latestReplyAt: project.lastMessageAt,
|
||||
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
|
||||
unreadCount: project.unreadCount,
|
||||
riskLevel: project.riskLevel,
|
||||
activeDeviceCount: devices.length,
|
||||
deviceNamesPreview: devices.map((device) => device.name),
|
||||
avatar: {
|
||||
primary: devices[0]?.avatar ?? "A",
|
||||
secondary: project.isGroup ? devices[1]?.avatar : undefined,
|
||||
overflowCount: Math.max(0, devices.length - 2) || undefined,
|
||||
},
|
||||
contextBudgetIndicator: {
|
||||
visible: !project.isGroup && Boolean(topThread),
|
||||
style: "ring_percent",
|
||||
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
|
||||
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
|
||||
},
|
||||
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
|
||||
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
|
||||
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
|
||||
} satisfies ConversationItem;
|
||||
});
|
||||
|
||||
return conversations.sort((a, b) => {
|
||||
if (a.projectId === "master-agent") return -1;
|
||||
if (b.projectId === "master-agent") return 1;
|
||||
if (a.manualPinned !== b.manualPinned) return a.manualPinned ? -1 : 1;
|
||||
return b.latestReplyAt.localeCompare(a.latestReplyAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectDetailView(state: BossState, projectId: string): ProjectDetailView | null {
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) return null;
|
||||
|
||||
const activeThreadContexts = threadViewsForProject(state, projectId);
|
||||
const threadsRequiringHandoff = activeThreadContexts.filter(
|
||||
(item) =>
|
||||
item.snapshot.mustFinishBeforeCompaction ||
|
||||
item.snapshot.contextBudgetLevel === "urgent" ||
|
||||
item.snapshot.contextBudgetLevel === "critical",
|
||||
);
|
||||
|
||||
const openFaults = state.opsFaults.filter(
|
||||
(fault) => fault.projectId === projectId && fault.status !== "resolved",
|
||||
);
|
||||
const relatedAuditResults = state.auditResults.filter((result) =>
|
||||
state.auditRequests.some(
|
||||
(request) => request.auditRequestId === result.auditRequestId && request.projectId === projectId,
|
||||
),
|
||||
);
|
||||
|
||||
const topRisk = threadsRequiringHandoff[0]?.snapshot ?? activeThreadContexts[0]?.snapshot;
|
||||
const masterContextStrategySummary = topRisk
|
||||
? `${topRisk.title} 需要优先处理,当前 ${topRisk.contextBudgetLevel} ${topRisk.contextBudgetRemainingPct}%${topRisk.mustFinishBeforeCompaction ? ",必须先固化 patch / 测试 / 证据" : ""}。`
|
||||
: "当前没有高风险线程,主 Agent 可以继续按正常优先级调度。";
|
||||
const projectDeviceIds = new Set(project.deviceIds);
|
||||
const recentAppLogs = [...state.appLogs]
|
||||
.filter((log) =>
|
||||
projectId === "master-agent"
|
||||
? true
|
||||
: log.projectId === projectId || projectDeviceIds.has(log.deviceId),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
project,
|
||||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
||||
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
|
||||
activeThreadContexts,
|
||||
nextCompactionRiskThreadId: topRisk?.threadId,
|
||||
threadsRequiringHandoff,
|
||||
masterContextStrategySummary,
|
||||
recentAppLogs,
|
||||
openFaults,
|
||||
relatedAuditResults,
|
||||
};
|
||||
}
|
||||
|
||||
export function getThreadContextDetailView(
|
||||
state: BossState,
|
||||
threadId: string,
|
||||
): ThreadContextDetailView | null {
|
||||
const snapshot = state.threadContextSnapshots.find((item) => item.threadId === threadId);
|
||||
if (!snapshot) return null;
|
||||
const handoffPackage = state.threadHandoffPackages.find(
|
||||
(item) => item.fromThreadId === threadId && item.packageStatus !== "expired",
|
||||
);
|
||||
const alerts = state.threadContextAlerts.filter((item) => item.threadId === threadId);
|
||||
const masterActions = Array.from(
|
||||
new Set(alerts.flatMap((alert) => alert.masterActions)),
|
||||
);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
handoffPackage,
|
||||
alerts,
|
||||
currentChecklist: snapshot.checklist,
|
||||
masterActions,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDeviceWorkspaceView(
|
||||
state: BossState,
|
||||
deviceId?: string,
|
||||
): DeviceWorkspaceView {
|
||||
if (!deviceId) {
|
||||
return {
|
||||
relatedThreads: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
selectedDevice: state.devices.find((item) => item.id === deviceId),
|
||||
relatedThreads: state.threadContextSnapshots.filter((item) => item.nodeId === deviceId),
|
||||
activeEnrollment: state.deviceEnrollments.find((item) => item.deviceId === deviceId),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpsSummaryView(state: BossState): OpsSummaryView {
|
||||
const tickets = state.opsRepairTickets.map((ticket) => ({
|
||||
...ticket,
|
||||
verification: state.opsRepairVerifications.find((item) => item.ticketId === ticket.ticketId),
|
||||
}));
|
||||
const mode =
|
||||
state.opsFaults.some((fault) => fault.status !== "resolved") ||
|
||||
state.threadContextSnapshots.some(
|
||||
(snapshot) =>
|
||||
snapshot.contextBudgetLevel === "urgent" || snapshot.contextBudgetLevel === "critical",
|
||||
)
|
||||
? "active"
|
||||
: "idle";
|
||||
|
||||
return {
|
||||
mode,
|
||||
faults: [...state.opsFaults].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)),
|
||||
tickets: tickets.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuditSummaryView(state: BossState): AuditSummaryView {
|
||||
const completedIds = new Set(state.auditResults.map((result) => result.auditRequestId));
|
||||
return {
|
||||
pendingRequests: state.auditRequests.filter(
|
||||
(request) => !completedIds.has(request.auditRequestId),
|
||||
),
|
||||
latestResults: [...state.auditResults].sort((a, b) =>
|
||||
b.completedAt.localeCompare(a.completedAt),
|
||||
),
|
||||
capabilities: state.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSkillInventoryView(state: BossState): SkillInventoryView {
|
||||
return getSkillInventoryViewForAccount(
|
||||
state,
|
||||
state.user.account,
|
||||
state.user.boundDeviceId,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillInventoryViewForAccount(
|
||||
state: BossState,
|
||||
account: string,
|
||||
boundDeviceId?: string,
|
||||
): SkillInventoryView {
|
||||
const devices = state.devices
|
||||
.filter(
|
||||
(device) =>
|
||||
device.account === account || device.id === boundDeviceId,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.id === boundDeviceId) return -1;
|
||||
if (b.id === boundDeviceId) return 1;
|
||||
return b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||||
});
|
||||
|
||||
return {
|
||||
boundDeviceId,
|
||||
groups: devices
|
||||
.map((device) => ({
|
||||
device,
|
||||
skills: state.deviceSkills
|
||||
.filter((skill) => skill.deviceId === device.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
|
||||
}))
|
||||
.filter((group) => group.skills.length > 0),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user