update
This commit is contained in:
1 parent
67c757f481
commit
b8dda3c8d0
77 files changed
+1577
-2808
No files matched your search
@@ -100,7 +100,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
|
||||
|
||||
const onBrowserAction = server.onBrowserAction_;
|
||||
if (onBrowserAction) {
|
||||
onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record<string, unknown>);
|
||||
onBrowserAction(actionId, companyId);
|
||||
}
|
||||
|
||||
server.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" });
|
||||
@@ -183,10 +183,25 @@ export function registerMiscRoutes(server: AgentApiServer) {
|
||||
server.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const data = body as Record<string, unknown>;
|
||||
const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]";
|
||||
|
||||
const meetingCreateSchema = z.object({
|
||||
type: z.string().default("department_sync"),
|
||||
title: z.string().default("Meeting"),
|
||||
participantAgentIds: z.string().default("[]"),
|
||||
scheduledAt: z.string().default(new Date().toISOString()),
|
||||
durationMinutes: z.number().default(30),
|
||||
agendaJson: z.string().default("[]"),
|
||||
});
|
||||
|
||||
const parsed = meetingCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
try {
|
||||
const parsedParticipants = JSON.parse(participantAgentIds) as string[];
|
||||
const parsedParticipants = JSON.parse(input.participantAgentIds) as string[];
|
||||
for (const agentId of parsedParticipants) {
|
||||
if (typeof agentId !== "string" || !db.belongsToCompany("agents", agentId, token.companyId)) {
|
||||
server.json(res, 400, { error: "Meeting participants must belong to the current company" });
|
||||
@@ -199,13 +214,13 @@ export function registerMiscRoutes(server: AgentApiServer) {
|
||||
}
|
||||
const id = db.saveMeeting({
|
||||
companyId: token.companyId,
|
||||
type: String(data.type ?? "department_sync"),
|
||||
title: String(data.title ?? "Meeting"),
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
organizerAgentId: token.agentId,
|
||||
participantAgentIds,
|
||||
scheduledAt: String(data.scheduledAt ?? new Date().toISOString()),
|
||||
durationMinutes: Number(data.durationMinutes ?? 30),
|
||||
agendaJson: data.agendaJson ? String(data.agendaJson) : "[]",
|
||||
participantAgentIds: input.participantAgentIds,
|
||||
scheduledAt: input.scheduledAt,
|
||||
durationMinutes: input.durationMinutes,
|
||||
agendaJson: input.agendaJson,
|
||||
status: "scheduled",
|
||||
});
|
||||
onDomainChanged();
|
||||
@@ -229,11 +244,28 @@ export function registerMiscRoutes(server: AgentApiServer) {
|
||||
server.json(res, 404, { error: "Meeting not found" });
|
||||
return;
|
||||
}
|
||||
const data = body as Record<string, unknown>;
|
||||
|
||||
const meetingUpdateSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
notesJson: z.string().optional(),
|
||||
decisionsJson: z.string().optional(),
|
||||
actionItemsJson: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
scheduledAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const parsed = meetingUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
|
||||
if (data.actionItemsJson) {
|
||||
try {
|
||||
actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
|
||||
actionItems = JSON.parse(data.actionItemsJson) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
|
||||
} catch {
|
||||
server.json(res, 400, { error: "actionItemsJson must be valid JSON" });
|
||||
return;
|
||||
@@ -248,13 +280,13 @@ export function registerMiscRoutes(server: AgentApiServer) {
|
||||
db.saveMeeting({
|
||||
id: params.meetingId,
|
||||
companyId: token.companyId,
|
||||
type: data.type ? String(data.type) : "department_sync",
|
||||
title: data.title ? String(data.title) : "Meeting",
|
||||
notesJson: data.notesJson ? String(data.notesJson) : undefined,
|
||||
decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined,
|
||||
actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined,
|
||||
status: data.status ? String(data.status) : undefined,
|
||||
scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(),
|
||||
type: data.type ?? "department_sync",
|
||||
title: data.title ?? "Meeting",
|
||||
notesJson: data.notesJson,
|
||||
decisionsJson: data.decisionsJson,
|
||||
actionItemsJson: data.actionItemsJson,
|
||||
status: data.status,
|
||||
scheduledAt: data.scheduledAt ?? new Date().toISOString(),
|
||||
});
|
||||
onDomainChanged();
|
||||
// When action items are added, create tasks for them automatically
|
||||
@@ -291,17 +323,34 @@ export function registerMiscRoutes(server: AgentApiServer) {
|
||||
server.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const data = body as Record<string, unknown>;
|
||||
|
||||
const knowledgeCreateSchema = z.object({
|
||||
category: z.string().default("lesson_learned"),
|
||||
topic: z.string().default(""),
|
||||
content: z.string().default(""),
|
||||
importance: z.string().default("medium"),
|
||||
tagsJson: z.string().default("[]"),
|
||||
referencedEntityType: z.string().nullable().optional(),
|
||||
referencedEntityId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const parsed = knowledgeCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
server.json(res, 400, { error: "Invalid knowledge entry payload", details: parsed.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const id = db.saveKnowledgeEntry({
|
||||
companyId: token.companyId,
|
||||
category: String(data.category ?? "lesson_learned"),
|
||||
topic: String(data.topic ?? ""),
|
||||
content: String(data.content ?? ""),
|
||||
category: input.category,
|
||||
topic: input.topic,
|
||||
content: input.content,
|
||||
authorAgentId: token.agentId,
|
||||
importance: String(data.importance ?? "medium"),
|
||||
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
|
||||
referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null,
|
||||
referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null,
|
||||
importance: input.importance,
|
||||
tagsJson: input.tagsJson,
|
||||
referencedEntityType: input.referencedEntityType ?? null,
|
||||
referencedEntityId: input.referencedEntityId ?? null,
|
||||
sourceRunId: token.runId ?? null,
|
||||
});
|
||||
onDomainChanged();
|
||||
|
||||
@@ -67,7 +67,7 @@ export class AgentApiServer {
|
||||
private emitEventCb: (event: DesktopEvent) => void;
|
||||
private callbacks: ApiServerEventCallbacks;
|
||||
private allowedOrigins: Set<string>;
|
||||
private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
|
||||
private onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
db: AppDatabase,
|
||||
@@ -97,7 +97,7 @@ export class AgentApiServer {
|
||||
};
|
||||
}
|
||||
|
||||
setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) {
|
||||
setBrowserActionHandler(handler: (actionId: string, companyId: string) => void) {
|
||||
this.onBrowserActionCb = handler;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ export class AgentApiServer {
|
||||
get onDomainChanged_(): () => void { return this.onDomainChangedCb; }
|
||||
get onWakeAgent_(): (agentId: string, companyId: string, trigger: string) => string | null { return this.onWakeAgentCb; }
|
||||
get emitEvent_(): (event: DesktopEvent) => void { return this.emitEventCb; }
|
||||
get onBrowserAction_(): ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null {
|
||||
get onBrowserAction_(): ((actionId: string, companyId: string) => void) | null {
|
||||
return this.onBrowserActionCb;
|
||||
}
|
||||
get port_(): number { return this.port; }
|
||||
|
||||
@@ -127,11 +127,16 @@ export function createBrowserActionService({
|
||||
browserManagerRef.current = new BrowserManager(join(db.artifactsDir, "browser"));
|
||||
}
|
||||
|
||||
async function extractProfileFromSession(socialAccountId: string, platform: string) {
|
||||
return requireBrowserManager().extractProfileFromSession(socialAccountId, platform);
|
||||
}
|
||||
|
||||
return {
|
||||
dispatchBrowserAction,
|
||||
clearSocialAccountData,
|
||||
cancelAction,
|
||||
launchLoginBrowser,
|
||||
extractProfileFromSession,
|
||||
resetBrowserManager,
|
||||
};
|
||||
}
|
||||
@@ -231,6 +231,33 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
async extractProfileFromSession(socialAccountId: string, platform: string): Promise<{ accountName: string; displayName: string }> {
|
||||
const ses = this.getSession(socialAccountId);
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { session: ses, nodeIntegration: false, contextIsolation: true, sandbox: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const rules = getPlatformRules(platform);
|
||||
await win.loadURL(rules.authCheckUrl);
|
||||
await waitForLoad(win, 12000);
|
||||
const result = await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const meta = document.querySelector('meta[property="al:ios:url"], meta[name="user-login"], meta[property="profile:username"]');
|
||||
if (meta) return { accountName: meta.getAttribute("content") || "", displayName: "" };
|
||||
const title = document.title || "";
|
||||
return { accountName: "", displayName: title.split(/[|–—\\-/]/).map(s => s.trim()).find(s => s.length > 0 && s.length < 40) || "" };
|
||||
})()
|
||||
`);
|
||||
return result && (result.accountName || result.displayName) ? result : { accountName: "", displayName: "" };
|
||||
} catch {
|
||||
return { accountName: "", displayName: "" };
|
||||
} finally {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [, win] of this.loginWindows) {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
|
||||
@@ -85,6 +85,83 @@ export const PLATFORM_RULES: Record<string, PlatformRule> = {
|
||||
cookieOrigins: ["https://bsky.app"],
|
||||
unauthenticatedUrlMarkers: ["/login"],
|
||||
},
|
||||
discord: {
|
||||
loginUrl: "https://discord.com/login",
|
||||
authCheckUrl: "https://discord.com/channels/@me",
|
||||
allowedHosts: ["discord.com"],
|
||||
cookieOrigins: ["https://discord.com"],
|
||||
unauthenticatedUrlMarkers: ["/login"],
|
||||
},
|
||||
threads: {
|
||||
loginUrl: "https://www.threads.net/login",
|
||||
authCheckUrl: "https://www.threads.net/",
|
||||
allowedHosts: ["threads.net"],
|
||||
cookieOrigins: ["https://www.threads.net"],
|
||||
unauthenticatedUrlMarkers: ["/login"],
|
||||
},
|
||||
pinterest: {
|
||||
loginUrl: "https://www.pinterest.com/login/",
|
||||
authCheckUrl: "https://www.pinterest.com/",
|
||||
allowedHosts: ["pinterest.com"],
|
||||
cookieOrigins: ["https://www.pinterest.com"],
|
||||
unauthenticatedUrlMarkers: ["/login"],
|
||||
},
|
||||
telegram: {
|
||||
loginUrl: "https://web.telegram.org/",
|
||||
authCheckUrl: "https://web.telegram.org/",
|
||||
allowedHosts: ["telegram.org", "t.me"],
|
||||
cookieOrigins: ["https://web.telegram.org"],
|
||||
unauthenticatedUrlMarkers: ["/auth"],
|
||||
},
|
||||
whatsapp: {
|
||||
loginUrl: "https://web.whatsapp.com/",
|
||||
authCheckUrl: "https://web.whatsapp.com/",
|
||||
allowedHosts: ["whatsapp.com"],
|
||||
cookieOrigins: ["https://web.whatsapp.com"],
|
||||
unauthenticatedUrlMarkers: [],
|
||||
},
|
||||
wechat: {
|
||||
loginUrl: "https://mp.weixin.qq.com/",
|
||||
authCheckUrl: "https://mp.weixin.qq.com/",
|
||||
allowedHosts: ["mp.weixin.qq.com", "weixin.qq.com"],
|
||||
cookieOrigins: ["https://mp.weixin.qq.com"],
|
||||
unauthenticatedUrlMarkers: ["/cgi-bin/loginpage"],
|
||||
},
|
||||
weibo: {
|
||||
loginUrl: "https://weibo.com/login.php",
|
||||
authCheckUrl: "https://weibo.com/",
|
||||
allowedHosts: ["weibo.com", "weibo.cn"],
|
||||
cookieOrigins: ["https://weibo.com"],
|
||||
unauthenticatedUrlMarkers: ["/login", "/signup"],
|
||||
},
|
||||
xiaohongshu: {
|
||||
loginUrl: "https://www.xiaohongshu.com/explore",
|
||||
authCheckUrl: "https://www.xiaohongshu.com/",
|
||||
allowedHosts: ["xiaohongshu.com"],
|
||||
cookieOrigins: ["https://www.xiaohongshu.com"],
|
||||
unauthenticatedUrlMarkers: ["/login"],
|
||||
},
|
||||
douyin: {
|
||||
loginUrl: "https://www.douyin.com/",
|
||||
authCheckUrl: "https://www.douyin.com/",
|
||||
allowedHosts: ["douyin.com"],
|
||||
cookieOrigins: ["https://www.douyin.com"],
|
||||
unauthenticatedUrlMarkers: [],
|
||||
},
|
||||
bilibili: {
|
||||
loginUrl: "https://passport.bilibili.com/login",
|
||||
authCheckUrl: "https://www.bilibili.com/",
|
||||
allowedHosts: ["bilibili.com"],
|
||||
cookieOrigins: ["https://www.bilibili.com", "https://passport.bilibili.com"],
|
||||
unauthenticatedUrlMarkers: ["/login"],
|
||||
},
|
||||
zhihu: {
|
||||
loginUrl: "https://www.zhihu.com/signin",
|
||||
authCheckUrl: "https://www.zhihu.com/",
|
||||
allowedHosts: ["zhihu.com"],
|
||||
cookieOrigins: ["https://www.zhihu.com"],
|
||||
unauthenticatedUrlMarkers: ["/signin", "/sign-in"],
|
||||
},
|
||||
other: {
|
||||
loginUrl: "https://google.com",
|
||||
authCheckUrl: "https://google.com",
|
||||
|
||||
@@ -71,6 +71,17 @@ export function getComposeUrl(platform: string): string {
|
||||
bluesky: "https://bsky.app/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
discord: "https://discord.com/channels/@me",
|
||||
threads: "https://www.threads.net/",
|
||||
pinterest: "https://www.pinterest.com/pin-creation-tool/",
|
||||
telegram: "https://web.telegram.org/",
|
||||
whatsapp: "https://web.whatsapp.com/",
|
||||
wechat: "https://mp.weixin.qq.com/",
|
||||
weibo: "https://weibo.com/",
|
||||
xiaohongshu: "https://www.xiaohongshu.com/explore",
|
||||
douyin: "https://www.douyin.com/",
|
||||
bilibili: "https://www.bilibili.com/",
|
||||
zhihu: "https://www.zhihu.com/question/waiting",
|
||||
};
|
||||
return urls[platform] || getPlatformRules(platform).loginUrl;
|
||||
}
|
||||
@@ -85,6 +96,17 @@ export function getFeedUrl(platform: string): string {
|
||||
bluesky: "https://bsky.app/",
|
||||
youtube: "https://www.youtube.com/",
|
||||
tiktok: "https://www.tiktok.com/",
|
||||
discord: "https://discord.com/channels/@me",
|
||||
threads: "https://www.threads.net/",
|
||||
pinterest: "https://www.pinterest.com/",
|
||||
telegram: "https://web.telegram.org/",
|
||||
whatsapp: "https://web.whatsapp.com/",
|
||||
wechat: "https://mp.weixin.qq.com/",
|
||||
weibo: "https://weibo.com/",
|
||||
xiaohongshu: "https://www.xiaohongshu.com/explore",
|
||||
douyin: "https://www.douyin.com/",
|
||||
bilibili: "https://www.bilibili.com/",
|
||||
zhihu: "https://www.zhihu.com/",
|
||||
};
|
||||
return urls[platform] || getPlatformRules(platform).loginUrl;
|
||||
}
|
||||
@@ -95,6 +117,12 @@ export function getSearchUrl(platform: string, query: string): string {
|
||||
twitter: `https://x.com/search?q=${q}`,
|
||||
linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`,
|
||||
reddit: `https://www.reddit.com/search/?q=${q}`,
|
||||
discord: `https://discord.com/channels/@me`,
|
||||
pinterest: `https://www.pinterest.com/search/pins/?q=${q}`,
|
||||
weibo: `https://s.weibo.com/weibo?q=${q}`,
|
||||
xiaohongshu: `https://www.xiaohongshu.com/search_result?keyword=${q}`,
|
||||
bilibili: `https://search.bilibili.com/all?keyword=${q}`,
|
||||
zhihu: `https://www.zhihu.com/search?type=content&q=${q}`,
|
||||
};
|
||||
return urls[platform] || `https://google.com/search?q=${q}`;
|
||||
}
|
||||
@@ -27,18 +27,16 @@ export interface BudgetGateResult {
|
||||
function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string {
|
||||
if (policy.scopeType === "agent") {
|
||||
try {
|
||||
const agents = db.listSnapshot({ companyId: null }).agents;
|
||||
const agent = agents.find((a) => a.id === policy.scopeId);
|
||||
return agent ? `Agent "${agent.name}"` : `Agent ${policy.scopeId.slice(0, 8)}`;
|
||||
const agent = db.getAgent(policy.scopeId);
|
||||
return `Agent "${agent.name}"`;
|
||||
} catch {
|
||||
return `Agent ${policy.scopeId.slice(0, 8)}`;
|
||||
}
|
||||
}
|
||||
if (policy.scopeType === "project") {
|
||||
try {
|
||||
const projects = db.listSnapshot({ companyId: null }).projects;
|
||||
const project = projects.find((p) => p.id === policy.scopeId);
|
||||
return project ? `Project "${project.name}"` : `Project ${policy.scopeId.slice(0, 8)}`;
|
||||
const row = db.queryAll<{ name: string }>("select name from projects where id = ?", policy.scopeId);
|
||||
return row.length > 0 ? `Project "${row[0].name}"` : `Project ${policy.scopeId.slice(0, 8)}`;
|
||||
} catch {
|
||||
return `Project ${policy.scopeId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,51 @@ export interface ConnectorDefinition {
|
||||
listModels?: () => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
const claudeCompatModule: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
};
|
||||
|
||||
const claudeCompatCapabilities: CapabilityMatrix = {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
};
|
||||
|
||||
const COMPAT_SLOT_IDS: ConnectorId[] = [
|
||||
"claude_compatible",
|
||||
"claude_compat_2",
|
||||
"claude_compat_3",
|
||||
"claude_compat_4",
|
||||
"claude_compat_5",
|
||||
"claude_compat_6",
|
||||
"claude_compat_7",
|
||||
"claude_compat_8",
|
||||
"claude_compat_9",
|
||||
"claude_compat_10",
|
||||
];
|
||||
|
||||
const claudeCompatSlots: ConnectorDefinition[] = COMPAT_SLOT_IDS.map((id, i) => ({
|
||||
id,
|
||||
label: i === 0 ? "Claude Code Compatible API" : `Claude Code Compatible API (${i + 1})`,
|
||||
description: i === 0
|
||||
? "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama)."
|
||||
: `Claude Code CLI compatible API slot ${i + 1} for a third-party provider.`,
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: claudeCompatCapabilities,
|
||||
configurationDoc: claudeDoc,
|
||||
module: claudeCompatModule,
|
||||
listModels: async () => claudeModels,
|
||||
}));
|
||||
|
||||
export const CONNECTORS: ConnectorDefinition[] = [
|
||||
{
|
||||
id: "codex_local",
|
||||
@@ -75,256 +120,7 @@ export const CONNECTORS: ConnectorDefinition[] = [
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compatible",
|
||||
label: "Claude Code Compatible API",
|
||||
description: "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama).",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_2",
|
||||
label: "Claude Code Compatible API (2)",
|
||||
description: "Second Claude Code CLI compatible API slot for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_3",
|
||||
label: "Claude Code Compatible API (3)",
|
||||
description: "Third Claude Code CLI compatible API slot for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_4",
|
||||
label: "Claude Code Compatible API (4)",
|
||||
description: "Claude Code CLI compatible API slot 4 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_5",
|
||||
label: "Claude Code Compatible API (5)",
|
||||
description: "Claude Code CLI compatible API slot 5 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_6",
|
||||
label: "Claude Code Compatible API (6)",
|
||||
description: "Claude Code CLI compatible API slot 6 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_7",
|
||||
label: "Claude Code Compatible API (7)",
|
||||
description: "Claude Code CLI compatible API slot 7 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_8",
|
||||
label: "Claude Code Compatible API (8)",
|
||||
description: "Claude Code CLI compatible API slot 8 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_9",
|
||||
label: "Claude Code Compatible API (9)",
|
||||
description: "Claude Code CLI compatible API slot 9 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_10",
|
||||
label: "Claude Code Compatible API (10)",
|
||||
description: "Claude Code CLI compatible API slot 10 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
...claudeCompatSlots,
|
||||
{
|
||||
id: "gemini_local",
|
||||
label: "Gemini CLI",
|
||||
|
||||
@@ -89,18 +89,27 @@ export function registerBrowserHandlers({
|
||||
return fail("ACCOUNT_NOT_FOUND", "Social account not found");
|
||||
}
|
||||
|
||||
browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then((result) => {
|
||||
browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then(async (result) => {
|
||||
if (result.success) {
|
||||
const latestAccount = db.getSocialAccount(input.socialAccountId, account.companyId);
|
||||
if (!latestAccount) {
|
||||
return;
|
||||
}
|
||||
let detectedName = "";
|
||||
let detectedDisplay = "";
|
||||
try {
|
||||
const profile = await browserActions.extractProfileFromSession(input.socialAccountId, account.platform);
|
||||
detectedName = profile.accountName;
|
||||
detectedDisplay = profile.displayName;
|
||||
} catch {
|
||||
// Profile extraction is best-effort; proceed with login status update
|
||||
}
|
||||
db.saveSocialAccount({
|
||||
id: latestAccount.id,
|
||||
companyId: latestAccount.companyId,
|
||||
platform: latestAccount.platform,
|
||||
accountName: latestAccount.accountName,
|
||||
displayName: latestAccount.displayName,
|
||||
accountName: detectedName || latestAccount.accountName,
|
||||
displayName: detectedDisplay || latestAccount.displayName,
|
||||
profileUrl: latestAccount.profileUrl,
|
||||
credentialSecretId: latestAccount.credentialSecretId,
|
||||
status: "logged_in",
|
||||
|
||||
@@ -17,6 +17,10 @@ interface BrowserActionControl {
|
||||
screenshotPath: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
extractProfileFromSession: (socialAccountId: string, platform: string) => Promise<{
|
||||
accountName: string;
|
||||
displayName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RegisterDomainHandlersDependencies {
|
||||
@@ -48,10 +52,4 @@ export interface RegisterDomainHandlersDependencies {
|
||||
onHumanMessage?: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
export function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
export { ok, fail } from "./ipc-core-common";
|
||||
@@ -77,135 +77,6 @@ export function normalizeMessagesForPrompt(
|
||||
// Applied when session state is restored, not at every message send.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A transcript entry from adapter session state.
|
||||
* Adapters serialize their conversation as arrays of these blocks.
|
||||
*/
|
||||
export interface TranscriptBlock {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string | TranscriptContentBlock[];
|
||||
}
|
||||
|
||||
export interface TranscriptContentBlock {
|
||||
type: string;
|
||||
id?: string;
|
||||
tool_use_id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
content?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair tool_use / tool_result pairing in a serialized transcript.
|
||||
* - Orphan tool_results (no matching tool_use) are removed.
|
||||
* - Unpaired tool_use blocks get a synthetic "[interrupted]" result appended.
|
||||
* - Duplicate tool_use IDs are deduplicated (keep first).
|
||||
*
|
||||
* Returns a repaired copy; does not mutate the input.
|
||||
*/
|
||||
export function repairToolPairing(transcript: TranscriptBlock[]): TranscriptBlock[] {
|
||||
const toolUseIds = new Set<string>();
|
||||
const toolResultIds = new Set<string>();
|
||||
|
||||
// Pass 1: collect all tool_use and tool_result IDs
|
||||
for (const block of transcript) {
|
||||
if (!Array.isArray(block.content)) continue;
|
||||
for (const part of block.content) {
|
||||
if (part.type === "tool_use" && part.id) {
|
||||
toolUseIds.add(part.id);
|
||||
}
|
||||
if (part.type === "tool_result" && part.tool_use_id) {
|
||||
toolResultIds.add(part.tool_use_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: filter orphan tool_results and deduplicate tool_use IDs
|
||||
const seenToolUseIds = new Set<string>();
|
||||
const repaired: TranscriptBlock[] = [];
|
||||
|
||||
for (const block of transcript) {
|
||||
if (typeof block.content === "string") {
|
||||
repaired.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filteredContent: TranscriptContentBlock[] = [];
|
||||
for (const part of block.content) {
|
||||
if (part.type === "tool_result" && part.tool_use_id && !toolUseIds.has(part.tool_use_id)) {
|
||||
continue; // orphan tool_result — remove
|
||||
}
|
||||
if (part.type === "tool_use" && part.id) {
|
||||
if (seenToolUseIds.has(part.id)) continue; // duplicate — remove
|
||||
seenToolUseIds.add(part.id);
|
||||
}
|
||||
filteredContent.push(part);
|
||||
}
|
||||
|
||||
if (filteredContent.length > 0) {
|
||||
repaired.push({ ...block, content: filteredContent });
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: add synthetic results for unpaired tool_use blocks
|
||||
const unpairedToolUseIds = [...toolUseIds].filter((id) => !toolResultIds.has(id));
|
||||
if (unpairedToolUseIds.length > 0) {
|
||||
const syntheticResults: TranscriptContentBlock[] = unpairedToolUseIds.map((id) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: id,
|
||||
content: "[interrupted — tool call did not complete before session ended]",
|
||||
}));
|
||||
repaired.push({
|
||||
role: "user",
|
||||
content: syntheticResults,
|
||||
});
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return repair statistics for a transcript.
|
||||
* Use for logging/diagnostics without modifying the transcript.
|
||||
*/
|
||||
export function diagnoseTranscript(transcript: TranscriptBlock[]): {
|
||||
totalBlocks: number;
|
||||
toolUseCount: number;
|
||||
toolResultCount: number;
|
||||
orphanResultCount: number;
|
||||
unpairedUseCount: number;
|
||||
} {
|
||||
const toolUseIds = new Set<string>();
|
||||
const toolResultIds = new Set<string>();
|
||||
let toolUseCount = 0;
|
||||
let toolResultCount = 0;
|
||||
|
||||
for (const block of transcript) {
|
||||
if (!Array.isArray(block.content)) continue;
|
||||
for (const part of block.content) {
|
||||
if (part.type === "tool_use" && part.id) {
|
||||
toolUseIds.add(part.id);
|
||||
toolUseCount++;
|
||||
}
|
||||
if (part.type === "tool_result" && part.tool_use_id) {
|
||||
toolResultIds.add(part.tool_use_id);
|
||||
toolResultCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orphanResultCount = [...toolResultIds].filter((id) => !toolUseIds.has(id)).length;
|
||||
const unpairedUseCount = [...toolUseIds].filter((id) => !toolResultIds.has(id)).length;
|
||||
|
||||
return {
|
||||
totalBlocks: transcript.length,
|
||||
toolUseCount,
|
||||
toolResultCount,
|
||||
orphanResultCount,
|
||||
unpairedUseCount,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommentRecord {
|
||||
taskId: string;
|
||||
|
||||
@@ -55,7 +55,7 @@ const RUBRICS: Record<TaskType, TaskRubric> = {
|
||||
};
|
||||
|
||||
/** Returns the rubric for the given task type, defaulting to "general" for unknown values. */
|
||||
export function getTaskTypeRubric(taskType: string): TaskRubric {
|
||||
function getTaskTypeRubric(taskType: string): TaskRubric {
|
||||
const key = taskType as TaskType;
|
||||
return RUBRICS[key] ?? RUBRICS.general;
|
||||
}
|
||||
|
||||
@@ -1,154 +1,16 @@
|
||||
/** Thread Integration — wires thread runtime into orchestration callbacks. */
|
||||
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { ExecutionMode, ThreadType } from "@shared/types";
|
||||
import type { ExecutionMode } from "@shared/types";
|
||||
import {
|
||||
type ThreadIntegrationDeps,
|
||||
findTaskThread,
|
||||
ensureTaskThread,
|
||||
ensureApprovalThread,
|
||||
announceApprovalToBoard,
|
||||
notifyThreadSubscribers,
|
||||
} from "./thread-helpers";
|
||||
|
||||
interface ThreadIntegrationDeps {
|
||||
db: AppDatabase;
|
||||
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
emitEvent?: (event: import("@shared/types").DesktopEvent) => void;
|
||||
publishDomainChanged?: () => void;
|
||||
notify?: (options: { title: string; body: string }) => void;
|
||||
}
|
||||
|
||||
function findTaskThread(db: AppDatabase, taskId: string, companyId: string) {
|
||||
const existing = db.listThreads({
|
||||
companyId,
|
||||
type: "task" as ThreadType,
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
limit: 1,
|
||||
});
|
||||
return existing[0] ?? null;
|
||||
}
|
||||
|
||||
function ensureTaskThread(db: AppDatabase, taskId: string, companyId: string, title: string) {
|
||||
const existing = findTaskThread(db, taskId, companyId);
|
||||
if (existing) return existing;
|
||||
|
||||
return db.createThread({
|
||||
companyId,
|
||||
title,
|
||||
type: "task" as ThreadType,
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
});
|
||||
}
|
||||
|
||||
function findApprovalThread(db: AppDatabase, approvalId: string, companyId: string) {
|
||||
const existing = db.listThreads({
|
||||
companyId,
|
||||
type: "approval" as ThreadType,
|
||||
relatedEntityType: "approval",
|
||||
relatedEntityId: approvalId,
|
||||
limit: 1,
|
||||
});
|
||||
return existing[0] ?? null;
|
||||
}
|
||||
|
||||
function ensureApprovalThread(db: AppDatabase, approvalId: string, companyId: string, title: string) {
|
||||
const existing = findApprovalThread(db, approvalId, companyId);
|
||||
if (existing) return existing;
|
||||
|
||||
return db.createThread({
|
||||
companyId,
|
||||
title,
|
||||
type: "approval" as ThreadType,
|
||||
relatedEntityType: "approval",
|
||||
relatedEntityId: approvalId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApprovalAnnouncementChannel(
|
||||
db: AppDatabase,
|
||||
companyId: string,
|
||||
taskId: string | null,
|
||||
requestedByAgentId: string | null,
|
||||
): { channel: "department" | "project" | "company"; channelTargetId: string | null } | null {
|
||||
if (requestedByAgentId) {
|
||||
try {
|
||||
const requester = db.getAgent(requestedByAgentId);
|
||||
if (requester.companyId === companyId && requester.department) {
|
||||
return { channel: "department", channelTargetId: requester.department };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to task/project/company targeting.
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
try {
|
||||
const task = db.getTask(taskId);
|
||||
if (task.companyId === companyId && task.projectId) {
|
||||
return { channel: "project", channelTargetId: task.projectId };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to company-wide routing.
|
||||
}
|
||||
}
|
||||
|
||||
return { channel: "company", channelTargetId: null };
|
||||
}
|
||||
|
||||
function announceApprovalToBoard(
|
||||
db: AppDatabase,
|
||||
approval: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
relatedTaskId: string | null;
|
||||
requestedByAgentId: string | null;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
},
|
||||
deps: Pick<ThreadIntegrationDeps, "emitEvent" | "publishDomainChanged">,
|
||||
): void {
|
||||
if (!approval.requestedByAgentId || !deps.emitEvent || !deps.publishDomainChanged) return;
|
||||
|
||||
const target = resolveApprovalAnnouncementChannel(
|
||||
db,
|
||||
approval.companyId,
|
||||
approval.relatedTaskId,
|
||||
approval.requestedByAgentId,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
const messageId = db.sendAgentMessage({
|
||||
companyId: approval.companyId,
|
||||
fromAgentId: approval.requestedByAgentId,
|
||||
channel: target.channel,
|
||||
channelTargetId: target.channelTargetId,
|
||||
subject: approval.payloadSummary,
|
||||
body: `@Board ${approval.impactSummary || approval.payloadSummary}`,
|
||||
priority: "urgent",
|
||||
senderKind: "agent",
|
||||
});
|
||||
const message = db.getAgentMessage(messageId, approval.companyId);
|
||||
if (message) {
|
||||
deps.emitEvent({ type: "new-message", message });
|
||||
}
|
||||
deps.publishDomainChanged();
|
||||
}
|
||||
|
||||
/** Check subscriber notifyOn preferences and fire desktop notification. */
|
||||
function notifyThreadSubscribers(
|
||||
db: AppDatabase,
|
||||
threadId: string,
|
||||
threadTitle: string,
|
||||
eventBody: string,
|
||||
notify?: (options: { title: string; body: string }) => void,
|
||||
): void {
|
||||
if (!notify) return;
|
||||
const subscriptions = db.listSubscriptions(threadId);
|
||||
for (const sub of subscriptions) {
|
||||
if (sub.subscriberType !== "user") continue;
|
||||
if (sub.notifyOn === "none") continue;
|
||||
if (sub.notifyOn === "mentions") {
|
||||
if (!eventBody.includes("@You") && !eventBody.includes("@user") && !eventBody.includes("@Board")) continue;
|
||||
}
|
||||
notify({ title: threadTitle, body: eventBody });
|
||||
break;
|
||||
}
|
||||
}
|
||||
export type { ThreadIntegrationDeps } from "./thread-helpers";
|
||||
|
||||
export function createThreadIntegration({ db, logger, emitEvent, publishDomainChanged, notify }: ThreadIntegrationDeps) {
|
||||
return {
|
||||
@@ -0,0 +1,151 @@
|
||||
/** Thread Integration — shared helpers and types. */
|
||||
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { ThreadType } from "@shared/types";
|
||||
|
||||
export interface ThreadIntegrationDeps {
|
||||
db: AppDatabase;
|
||||
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
emitEvent?: (event: import("@shared/types").DesktopEvent) => void;
|
||||
publishDomainChanged?: () => void;
|
||||
notify?: (options: { title: string; body: string }) => void;
|
||||
}
|
||||
|
||||
export function findTaskThread(db: AppDatabase, taskId: string, companyId: string) {
|
||||
const existing = db.listThreads({
|
||||
companyId,
|
||||
type: "task" as ThreadType,
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
limit: 1,
|
||||
});
|
||||
return existing[0] ?? null;
|
||||
}
|
||||
|
||||
export function ensureTaskThread(db: AppDatabase, taskId: string, companyId: string, title: string) {
|
||||
const existing = findTaskThread(db, taskId, companyId);
|
||||
if (existing) return existing;
|
||||
|
||||
return db.createThread({
|
||||
companyId,
|
||||
title,
|
||||
type: "task" as ThreadType,
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
});
|
||||
}
|
||||
|
||||
export function findApprovalThread(db: AppDatabase, approvalId: string, companyId: string) {
|
||||
const existing = db.listThreads({
|
||||
companyId,
|
||||
type: "approval" as ThreadType,
|
||||
relatedEntityType: "approval",
|
||||
relatedEntityId: approvalId,
|
||||
limit: 1,
|
||||
});
|
||||
return existing[0] ?? null;
|
||||
}
|
||||
|
||||
export function ensureApprovalThread(db: AppDatabase, approvalId: string, companyId: string, title: string) {
|
||||
const existing = findApprovalThread(db, approvalId, companyId);
|
||||
if (existing) return existing;
|
||||
|
||||
return db.createThread({
|
||||
companyId,
|
||||
title,
|
||||
type: "approval" as ThreadType,
|
||||
relatedEntityType: "approval",
|
||||
relatedEntityId: approvalId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApprovalAnnouncementChannel(
|
||||
db: AppDatabase,
|
||||
companyId: string,
|
||||
taskId: string | null,
|
||||
requestedByAgentId: string | null,
|
||||
): { channel: "department" | "project" | "company"; channelTargetId: string | null } | null {
|
||||
if (requestedByAgentId) {
|
||||
try {
|
||||
const requester = db.getAgent(requestedByAgentId);
|
||||
if (requester.companyId === companyId && requester.department) {
|
||||
return { channel: "department", channelTargetId: requester.department };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to task/project/company targeting.
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
try {
|
||||
const task = db.getTask(taskId);
|
||||
if (task.companyId === companyId && task.projectId) {
|
||||
return { channel: "project", channelTargetId: task.projectId };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to company-wide routing.
|
||||
}
|
||||
}
|
||||
|
||||
return { channel: "company", channelTargetId: null };
|
||||
}
|
||||
|
||||
export function announceApprovalToBoard(
|
||||
db: AppDatabase,
|
||||
approval: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
relatedTaskId: string | null;
|
||||
requestedByAgentId: string | null;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
},
|
||||
deps: Pick<ThreadIntegrationDeps, "emitEvent" | "publishDomainChanged">,
|
||||
): void {
|
||||
if (!approval.requestedByAgentId || !deps.emitEvent || !deps.publishDomainChanged) return;
|
||||
|
||||
const target = resolveApprovalAnnouncementChannel(
|
||||
db,
|
||||
approval.companyId,
|
||||
approval.relatedTaskId,
|
||||
approval.requestedByAgentId,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
const messageId = db.sendAgentMessage({
|
||||
companyId: approval.companyId,
|
||||
fromAgentId: approval.requestedByAgentId,
|
||||
channel: target.channel,
|
||||
channelTargetId: target.channelTargetId,
|
||||
subject: approval.payloadSummary,
|
||||
body: `@Board ${approval.impactSummary || approval.payloadSummary}`,
|
||||
priority: "urgent",
|
||||
senderKind: "agent",
|
||||
});
|
||||
const message = db.getAgentMessage(messageId, approval.companyId);
|
||||
if (message) {
|
||||
deps.emitEvent({ type: "new-message", message });
|
||||
}
|
||||
deps.publishDomainChanged();
|
||||
}
|
||||
|
||||
/** Check subscriber notifyOn preferences and fire desktop notification. */
|
||||
export function notifyThreadSubscribers(
|
||||
db: AppDatabase,
|
||||
threadId: string,
|
||||
threadTitle: string,
|
||||
eventBody: string,
|
||||
notify?: (options: { title: string; body: string }) => void,
|
||||
): void {
|
||||
if (!notify) return;
|
||||
const subscriptions = db.listSubscriptions(threadId);
|
||||
for (const sub of subscriptions) {
|
||||
if (sub.subscriberType !== "user") continue;
|
||||
if (sub.notifyOn === "none") continue;
|
||||
if (sub.notifyOn === "mentions") {
|
||||
if (!eventBody.includes("@You") && !eventBody.includes("@user") && !eventBody.includes("@Board")) continue;
|
||||
}
|
||||
notify({ title: threadTitle, body: eventBody });
|
||||
break;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -78,10 +78,12 @@ export async function handleWorkerMessage(
|
||||
const now = Date.now();
|
||||
const lastRenewal = leaseRenewalMap.get(event.runId) ?? 0;
|
||||
if (now - lastRenewal >= LEASE_RENEWAL_INTERVAL_MS) {
|
||||
const run = db.listSnapshot().runs.find((r) => r.id === event.runId);
|
||||
if (run) {
|
||||
try {
|
||||
const run = db.getRun(event.runId);
|
||||
db.renewTaskLease(run.taskId, run.id);
|
||||
leaseRenewalMap.set(event.runId, now);
|
||||
} catch {
|
||||
// Run not found — skip lease renewal
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -102,14 +104,16 @@ export async function handleWorkerMessage(
|
||||
});
|
||||
}
|
||||
if (event.status === "running") {
|
||||
const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId);
|
||||
if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) {
|
||||
logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`);
|
||||
worker?.postMessage({ type: "cancel-run", runId: run.id });
|
||||
}
|
||||
if (run) {
|
||||
try {
|
||||
const run = db.getRun(event.runId);
|
||||
if (run.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) {
|
||||
logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`);
|
||||
worker?.postMessage({ type: "cancel-run", runId: run.id });
|
||||
}
|
||||
db.renewTaskLease(run.taskId, run.id);
|
||||
onRunStarted?.(run.id, run.taskId, run.agentId, run.companyId);
|
||||
} catch {
|
||||
// Run not found — skip status handling
|
||||
}
|
||||
}
|
||||
emitEvent(event);
|
||||
@@ -117,9 +121,10 @@ export async function handleWorkerMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const run = snapshot.runs.find((entry) => entry.id === event.runId);
|
||||
if (!run) {
|
||||
let run: ReturnType<typeof db["getRun"]>;
|
||||
try {
|
||||
run = db.getRun(event.runId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useFocusTrap } from "../ui";
|
||||
|
||||
interface ClaudeSettingsDialogProps {
|
||||
claudeApiKey: string;
|
||||
setClaudeApiKey: (v: string) => void;
|
||||
claudeBaseUrl: string;
|
||||
setClaudeBaseUrl: (v: string) => void;
|
||||
claudeModel: string;
|
||||
setClaudeModel: (v: string) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
saving: boolean;
|
||||
currentLocale: AppLocale;
|
||||
}
|
||||
|
||||
export function ClaudeSettingsDialog({
|
||||
claudeApiKey,
|
||||
setClaudeApiKey,
|
||||
claudeBaseUrl,
|
||||
setClaudeBaseUrl,
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
onSave,
|
||||
onClose,
|
||||
saving,
|
||||
currentLocale,
|
||||
}: ClaudeSettingsDialogProps) {
|
||||
const trapRef = useFocusTrap(true);
|
||||
useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [onClose]);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/72 backdrop-blur-sm" onClick={onClose}>
|
||||
<div ref={trapRef} role="dialog" aria-modal="true" aria-label={currentLocale === "zh" ? "Claude Code 设置" : "Claude Code Settings"} className="w-full max-w-[min(420px,90vw)] rounded-lg border border-border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-semibold text-foreground">Claude Code {currentLocale === "zh" ? "设置" : "Settings"}</h3>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-1 text-muted-foreground transition hover:text-foreground"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="claude-api-key" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">API Key</label>
|
||||
<input
|
||||
id="claude-api-key"
|
||||
type="password"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
|
||||
value={claudeApiKey}
|
||||
onChange={(e) => setClaudeApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用订阅认证" : "Leave empty to use subscription auth"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="claude-base-url" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Base URL</label>
|
||||
<input
|
||||
id="claude-base-url"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
|
||||
value={claudeBaseUrl}
|
||||
onChange={(e) => setClaudeBaseUrl(e.target.value)}
|
||||
placeholder="https://api.anthropic.com"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用默认端点" : "Leave empty for default endpoint"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="claude-model" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Model</label>
|
||||
<input
|
||||
id="claude-model"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
|
||||
value={claudeModel}
|
||||
onChange={(e) => setClaudeModel(e.target.value)}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用连接器默认模型" : "Leave empty for connector default"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button type="button" onClick={onClose} className="rounded-lg px-4 py-2 text-[13px] font-medium text-muted-foreground transition hover:text-foreground">
|
||||
{currentLocale === "zh" ? "取消" : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? (currentLocale === "zh" ? "保存中..." : "Saving...") : (currentLocale === "zh" ? "保存" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
CheckCircle,
|
||||
GearSix,
|
||||
WarningCircle,
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
|
||||
interface ConnectorReadinessProps {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
currentLocale: AppLocale;
|
||||
hasRunnableConnector: boolean;
|
||||
onOpenClaudeSettings: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function ConnectorReadiness({
|
||||
connectors,
|
||||
currentLocale,
|
||||
hasRunnableConnector,
|
||||
onOpenClaudeSettings,
|
||||
t,
|
||||
}: ConnectorReadinessProps) {
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("onboarding.connectorReadiness")}
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{connectors.map((connector) => {
|
||||
const isChecking = connector.status === "detected";
|
||||
const isReady = connector.status === "ready";
|
||||
const isMissing = connector.status === "not_installed";
|
||||
const issue = !isChecking ? getConnectorExecutionReadinessIssue(connector) : null;
|
||||
return (
|
||||
<div key={connector.id} className="flex items-start gap-2.5 text-[13px]">
|
||||
{isChecking ? (
|
||||
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-[color:var(--muted-foreground)] border-t-[color:var(--primary)]" />
|
||||
) : isReady ? (
|
||||
<CheckCircle size={16} weight="fill" className="shrink-0 text-success" />
|
||||
) : isMissing ? (
|
||||
<XCircle size={16} weight="fill" className="shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<WarningCircle size={16} weight="fill" className="shrink-0 text-warning" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isReady ? "font-medium text-foreground" : isChecking ? "text-foreground" : "text-muted-foreground"}>
|
||||
{connector.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{isChecking ? (currentLocale === "zh" ? "检测中..." : "checking...") : connector.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{issue ? (
|
||||
<div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{issue}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{connector.id === "claude_local" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenClaudeSettings(); }}
|
||||
className="shrink-0 rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
title={currentLocale === "zh" ? "Claude Code 设置" : "Claude Code Settings"}
|
||||
>
|
||||
<GearSix size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!hasRunnableConnector ? (
|
||||
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3 text-[12px] text-warning">
|
||||
{t("onboarding.connectorWarning")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { CheckCircle, GlobeSimple } from "@phosphor-icons/react";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale";
|
||||
import { ActionButton } from "../ui";
|
||||
|
||||
interface LanguageStepProps {
|
||||
currentLocale: AppLocale;
|
||||
onLocaleChange: (locale: AppLocale) => void;
|
||||
onContinue: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export function LanguageStep({
|
||||
currentLocale,
|
||||
onLocaleChange,
|
||||
onContinue,
|
||||
t,
|
||||
}: LanguageStepProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="language"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -18 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<GlobeSimple size={12} />
|
||||
{t("onboarding.languageSelection")} / Language
|
||||
</div>
|
||||
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{t("onboarding.selectLanguage")} / Select Language
|
||||
</div>
|
||||
<div className="mb-6 h-px bg-[color:var(--border)]" />
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{APP_LOCALES.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
onClick={() => onLocaleChange(locale)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3.5 text-left transition ${
|
||||
currentLocale === locale
|
||||
? "border-[color:var(--primary)] bg-accent"
|
||||
: "border-border bg-muted hover:border-[color:var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border ${
|
||||
currentLocale === locale
|
||||
? "border-[color:var(--primary)] bg-primary"
|
||||
: "border-border"
|
||||
}`}>
|
||||
{currentLocale === locale ? <CheckCircle size={14} weight="fill" className="text-primary-foreground" /> : null}
|
||||
</div>
|
||||
<span className={`text-[14px] font-medium ${
|
||||
currentLocale === locale ? "text-foreground" : "text-muted-foreground"
|
||||
}`}>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-end">
|
||||
<ActionButton
|
||||
label={t("onboarding.continue")}
|
||||
onClick={onContinue}
|
||||
icon={GlobeSimple}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, X } from "@phosphor-icons/react";
|
||||
import { SignIn, X } from "@phosphor-icons/react";
|
||||
import type { SocialPlatform } from "@shared/types";
|
||||
import { ActionButton, Input } from "../ui";
|
||||
import { ActionButton } from "../ui";
|
||||
import { platformConfig } from "./social-config";
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void;
|
||||
onAddAndLogin: (platform: SocialPlatform, requireApproval: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
|
||||
export function AddAccountDialog({ onAddAndLogin, onClose }: AddAccountDialogProps) {
|
||||
const [platform, setPlatform] = useState<SocialPlatform>("twitter");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [requireApproval, setRequireApproval] = useState(true);
|
||||
|
||||
return (
|
||||
@@ -41,41 +39,29 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Platform</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setPlatform(key)}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-[11px] font-medium transition ${
|
||||
platform === key
|
||||
? "border-[color:var(--primary)] bg-accent text-primary"
|
||||
: "border-border text-muted-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span style={{ color: config.color }}>
|
||||
<config.icon size={14} />
|
||||
</span>
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-lg border border-border p-1.5">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setPlatform(key)}
|
||||
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[11px] font-medium transition ${
|
||||
platform === key
|
||||
? "border-[color:var(--primary)] bg-accent text-primary"
|
||||
: "border-border text-muted-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span style={{ color: config.color }}>
|
||||
<config.icon size={14} />
|
||||
</span>
|
||||
<span className="truncate">{config.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Username / Handle"
|
||||
value={accountName}
|
||||
onChange={setAccountName}
|
||||
placeholder={platform === "twitter" ? "elonmusk" : "your-username"}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Display Name (optional)"
|
||||
value={displayName}
|
||||
onChange={setDisplayName}
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -92,6 +78,10 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-accent px-4 py-3 text-[11px] text-muted-foreground">
|
||||
A browser window will open for you to sign in. Your username and profile info will be detected automatically after login.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
@@ -103,15 +93,10 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
|
||||
Cancel
|
||||
</button>
|
||||
<ActionButton
|
||||
label="Add Account"
|
||||
onClick={() => {
|
||||
if (accountName.trim()) {
|
||||
onAdd(platform, accountName.trim(), displayName.trim(), requireApproval);
|
||||
}
|
||||
}}
|
||||
icon={Plus}
|
||||
label="Login"
|
||||
onClick={() => onAddAndLogin(platform, requireApproval)}
|
||||
icon={SignIn}
|
||||
tone="accent"
|
||||
disabled={!accountName.trim()}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -13,13 +13,7 @@ interface SocialPanelProps {
|
||||
actions: BrowserActionRecord[];
|
||||
agents: Array<{ id: string; name: string }>;
|
||||
companyId: string;
|
||||
onSave: (input: {
|
||||
companyId: string;
|
||||
platform: SocialPlatform;
|
||||
accountName: string;
|
||||
displayName: string;
|
||||
requireApproval: boolean;
|
||||
}) => Promise<void>;
|
||||
onAddAndLogin: (platform: SocialPlatform, requireApproval: boolean) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onLogin: (accountId: string) => Promise<void>;
|
||||
onOpenPath: (path: string) => Promise<void>;
|
||||
@@ -31,8 +25,8 @@ export function SocialAccountsPanel({
|
||||
accounts,
|
||||
actions,
|
||||
agents,
|
||||
companyId,
|
||||
onSave,
|
||||
companyId: _companyId,
|
||||
onAddAndLogin,
|
||||
onDelete,
|
||||
onLogin,
|
||||
onOpenPath,
|
||||
@@ -43,10 +37,10 @@ export function SocialAccountsPanel({
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"accounts" | "history">("accounts");
|
||||
|
||||
const handleAdd = useCallback(async (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => {
|
||||
await onSave({ companyId, platform, accountName, displayName, requireApproval });
|
||||
const handleAddAndLogin = useCallback(async (platform: SocialPlatform, requireApproval: boolean) => {
|
||||
await onAddAndLogin(platform, requireApproval);
|
||||
setShowAddDialog(false);
|
||||
}, [companyId, onSave]);
|
||||
}, [onAddAndLogin]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -151,7 +145,7 @@ export function SocialAccountsPanel({
|
||||
<AnimatePresence>
|
||||
{showAddDialog ? (
|
||||
<AddAccountDialog
|
||||
onAdd={(platform, accountName, displayName, requireApproval) => void handleAdd(platform, accountName, displayName, requireApproval)}
|
||||
onAddAndLogin={(platform, requireApproval) => void handleAddAndLogin(platform, requireApproval)}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ElementType } from "react";
|
||||
import {
|
||||
Browser,
|
||||
Camera,
|
||||
ChatCircle,
|
||||
Eye,
|
||||
@@ -9,14 +8,23 @@ import {
|
||||
MagnifyingGlass,
|
||||
PaperPlaneTilt,
|
||||
UserPlus,
|
||||
DiscordLogo,
|
||||
FacebookLogo,
|
||||
GlobeHemisphereWest,
|
||||
InstagramLogo,
|
||||
LinkedinLogo,
|
||||
PinterestLogo,
|
||||
RedditLogo,
|
||||
TelegramLogo,
|
||||
TiktokLogo,
|
||||
WhatsappLogo,
|
||||
XLogo,
|
||||
YoutubeLogo,
|
||||
ChatTeardrop,
|
||||
MonitorPlay,
|
||||
Notebook,
|
||||
MusicNote,
|
||||
Lightbulb,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { SocialPlatform } from "@shared/types";
|
||||
|
||||
@@ -29,6 +37,17 @@ export const platformConfig: Record<SocialPlatform, { label: string; color: stri
|
||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
|
||||
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
|
||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
|
||||
discord: { label: "Discord", color: "#5865F2", bgColor: "#5865F214", icon: DiscordLogo },
|
||||
threads: { label: "Threads", color: "#000000", bgColor: "#00000014", icon: GlobeHemisphereWest },
|
||||
pinterest: { label: "Pinterest", color: "#E60023", bgColor: "#E6002314", icon: PinterestLogo },
|
||||
telegram: { label: "Telegram", color: "#26A5E4", bgColor: "#26A5E414", icon: TelegramLogo },
|
||||
whatsapp: { label: "WhatsApp", color: "#25D366", bgColor: "#25D36614", icon: WhatsappLogo },
|
||||
wechat: { label: "微信 WeChat", color: "#07C160", bgColor: "#07C16014", icon: ChatTeardrop },
|
||||
weibo: { label: "微博 Weibo", color: "#E6162D", bgColor: "#E6162D14", icon: Globe },
|
||||
xiaohongshu: { label: "小红书 RED", color: "#FE2C55", bgColor: "#FE2C5514", icon: Notebook },
|
||||
douyin: { label: "抖音 Douyin", color: "#161823", bgColor: "#16182314", icon: MusicNote },
|
||||
bilibili: { label: "哔哩哔哩 Bilibili", color: "#00A1D6", bgColor: "#00A1D614", icon: MonitorPlay },
|
||||
zhihu: { label: "知乎 Zhihu", color: "#0066FF", bgColor: "#0066FF14", icon: Lightbulb },
|
||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
|
||||
};
|
||||
|
||||
@@ -41,6 +60,4 @@ export const actionTypeLabels: Record<string, { label: string; icon: React.Eleme
|
||||
screenshot: { label: "Screenshot", icon: Camera },
|
||||
navigate: { label: "Navigate", icon: Globe },
|
||||
search: { label: "Search", icon: MagnifyingGlass },
|
||||
dm: { label: "Direct Message", icon: ChatCircle },
|
||||
custom_script: { label: "Legacy Action", icon: Browser },
|
||||
};
|
||||
@@ -40,13 +40,13 @@ const sidebarNavDefs: NavItemDef[] = [
|
||||
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
|
||||
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
|
||||
{ id: "workspaces", labelKey: "nav.workspaces", icon: FolderOpen },
|
||||
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
|
||||
{ id: "social", labelKey: "nav.social", icon: Megaphone },
|
||||
{ id: "documents", labelKey: "nav.documents", icon: FileText },
|
||||
];
|
||||
|
||||
const paletteOnlyNavDefs: NavItemDef[] = [
|
||||
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
|
||||
{ id: "social", labelKey: "nav.social", icon: Megaphone },
|
||||
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
|
||||
];
|
||||
|
||||
export function useAppController() {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Company Context Provider
|
||||
*
|
||||
* Provides shared company infrastructure to section components, eliminating
|
||||
* the need to thread 80+ props through SectionContentView. Components can
|
||||
* import useCompanyContext() instead of receiving props from the root.
|
||||
*
|
||||
* This context holds stable references to company data and actions that
|
||||
* nearly every section needs: companyId, snapshot, refresh, navigation, etc.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
|
||||
|
||||
export interface CompanyContextValue {
|
||||
companyId: string | null;
|
||||
currentCompany: CompanyRecord | null;
|
||||
snapshot: ProfileSnapshot | null;
|
||||
refresh: () => void;
|
||||
navigateTo: (section: SectionId, entityId?: string) => void;
|
||||
setSection: (section: SectionId) => void;
|
||||
setError: (error: string | null) => void;
|
||||
openPath: (targetPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
||||
|
||||
interface CompanyContextProviderProps {
|
||||
companyId: string | null;
|
||||
currentCompany: CompanyRecord | null;
|
||||
snapshot: ProfileSnapshot | null;
|
||||
refresh: () => void;
|
||||
navigateTo: (section: SectionId, entityId?: string) => void;
|
||||
setSection: (section: SectionId) => void;
|
||||
setError: (error: string | null) => void;
|
||||
openPath: (targetPath: string) => Promise<void>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CompanyContextProvider({
|
||||
companyId, currentCompany, snapshot, refresh,
|
||||
navigateTo, setSection, setError, openPath, children,
|
||||
}: CompanyContextProviderProps) {
|
||||
const value = useMemo<CompanyContextValue>(() => ({
|
||||
companyId, currentCompany, snapshot, refresh,
|
||||
navigateTo, setSection, setError, openPath,
|
||||
}), [companyId, currentCompany, snapshot, refresh, navigateTo, setSection, setError, openPath]);
|
||||
|
||||
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCompanyContext(): CompanyContextValue {
|
||||
const ctx = useContext(CompanyContext);
|
||||
if (!ctx) throw new Error("useCompanyContext must be used within CompanyContextProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface RovingTabIndexResult {
|
||||
containerRef: { current: HTMLDivElement | null };
|
||||
focusedIndex: number;
|
||||
@@ -6,11 +8,11 @@ interface RovingTabIndexResult {
|
||||
getTabIndex: (index: number) => 0 | -1;
|
||||
}
|
||||
|
||||
/** Roving tabindex for keyboard nav in lists. Supports Arrow/Home/End/j/k with wrap-around. */
|
||||
export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
|
||||
// Use internal mutable state for testability outside React
|
||||
// In a React component context, this still works correctly because
|
||||
// the function is called once per render and returns fresh closures
|
||||
/**
|
||||
* Core roving-tabindex logic, independent of React.
|
||||
* Used directly by tests and wrapped by `useRovingTabIndex` for React components.
|
||||
*/
|
||||
export function createRovingTabIndex(itemCount: number): RovingTabIndexResult {
|
||||
let _focusedIndex = 0;
|
||||
const containerRef = { current: null as HTMLDivElement | null };
|
||||
|
||||
@@ -41,7 +43,6 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
|
||||
|
||||
_focusedIndex = nextIndex;
|
||||
|
||||
// Attempt to focus the target element in the DOM (no-op in tests)
|
||||
const target = containerRef.current?.querySelector(
|
||||
`[data-index="${nextIndex}"]`,
|
||||
) as HTMLElement | null;
|
||||
@@ -62,3 +63,57 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
|
||||
getTabIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/** React hook wrapper around roving tabindex. Uses real React state for reactivity in components. */
|
||||
export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
|
||||
const [focusedIndex, setFocusedIndexState] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusedRef = useRef(0);
|
||||
|
||||
const setFocusedIndex = useCallback((index: number) => {
|
||||
focusedRef.current = index;
|
||||
setFocusedIndexState(index);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => {
|
||||
if (itemCount === 0) return;
|
||||
|
||||
let nextIndex = focusedRef.current;
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "j") {
|
||||
event.preventDefault();
|
||||
nextIndex = (focusedRef.current + 1) % itemCount;
|
||||
} else if (event.key === "ArrowUp" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
nextIndex = (focusedRef.current - 1 + itemCount) % itemCount;
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
nextIndex = 0;
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
nextIndex = itemCount - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
focusedRef.current = nextIndex;
|
||||
setFocusedIndexState(nextIndex);
|
||||
|
||||
const target = containerRef.current?.querySelector(
|
||||
`[data-index="${nextIndex}"]`,
|
||||
) as HTMLElement | null;
|
||||
target?.focus();
|
||||
}, [itemCount]);
|
||||
|
||||
const getTabIndex = useCallback((index: number): 0 | -1 => {
|
||||
return index === focusedRef.current ? 0 : -1;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
handleKeyDown,
|
||||
getTabIndex,
|
||||
};
|
||||
}
|
||||
@@ -153,19 +153,21 @@ export function OperationsSections({
|
||||
actions={browserActions}
|
||||
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
|
||||
companyId={snapshot.currentCompanyId}
|
||||
onSave={async (input) => {
|
||||
onAddAndLogin={async (platform, requireApproval) => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveSocialAccount({
|
||||
companyId: input.companyId,
|
||||
platform: input.platform,
|
||||
accountName: input.accountName,
|
||||
displayName: input.displayName,
|
||||
requireApproval: input.requireApproval,
|
||||
const accountId = unwrap(await window.agentCompany.saveSocialAccount({
|
||||
companyId: snapshot.currentCompanyId,
|
||||
platform,
|
||||
accountName: platform,
|
||||
displayName: "",
|
||||
requireApproval,
|
||||
profileUrl: "",
|
||||
status: "login_required",
|
||||
metadataJson: "{}",
|
||||
}));
|
||||
await refresh();
|
||||
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -35,12 +35,3 @@ export function mergeConnectorModelEntries(
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSelectedConnectorModel(
|
||||
entries: ConnectorModelEntry[],
|
||||
currentModel: string,
|
||||
): string {
|
||||
if (currentModel && entries.some((entry) => entry.enabled && entry.id === currentModel)) {
|
||||
return currentModel;
|
||||
}
|
||||
return entries.find((entry) => entry.enabled)?.id ?? "";
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const approvalInputSchema = z.object({
|
||||
relatedTaskId: optionalId,
|
||||
requestedByAgentId: optionalId,
|
||||
relatedAgentId: optionalId,
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review"]),
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review", "budget_override", "plan_review", "scope_review", "draft_review", "strategy_review", "exception_review"]),
|
||||
payloadSummary: z.string().min(2).max(280),
|
||||
impactSummary: z.string().min(2).max(400),
|
||||
payloadJson: z.string().nullable().optional(),
|
||||
|
||||
@@ -58,7 +58,7 @@ export const bootstrapOnboardingSchema = z.object({
|
||||
export const socialAccountInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
platform: z.enum(["twitter", "linkedin", "instagram", "facebook", "reddit", "youtube", "tiktok", "bluesky", "other"]),
|
||||
platform: z.enum(["twitter", "linkedin", "instagram", "facebook", "reddit", "youtube", "tiktok", "bluesky", "discord", "threads", "pinterest", "telegram", "whatsapp", "wechat", "weibo", "xiaohongshu", "douyin", "bilibili", "zhihu", "other"]),
|
||||
accountName: z.string().min(1).max(120),
|
||||
displayName: z.string().max(120).default(""),
|
||||
profileUrl: z.string().max(500).default(""),
|
||||
|
||||
@@ -115,7 +115,27 @@ export type BudgetAlertLevel = "warn" | "hard_stop";
|
||||
export type SkillSourceType = "github" | "local_path" | "url" | "skills_sh" | "managed";
|
||||
export type TrustLevel = "markdown_only" | "assets" | "scripts_executables";
|
||||
|
||||
export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other";
|
||||
export type SocialPlatform =
|
||||
| "twitter"
|
||||
| "linkedin"
|
||||
| "instagram"
|
||||
| "facebook"
|
||||
| "reddit"
|
||||
| "youtube"
|
||||
| "tiktok"
|
||||
| "bluesky"
|
||||
| "discord"
|
||||
| "threads"
|
||||
| "pinterest"
|
||||
| "telegram"
|
||||
| "whatsapp"
|
||||
| "wechat"
|
||||
| "weibo"
|
||||
| "xiaohongshu"
|
||||
| "douyin"
|
||||
| "bilibili"
|
||||
| "zhihu"
|
||||
| "other";
|
||||
export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused";
|
||||
export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search";
|
||||
export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required";
|
||||
|
||||
Reference in new issue
Block a user