experiment: two-layer observer, canned openers, structured action signals
Layer 1 (instant): detectUserIntent() on user message creates work product + fires background artifact generation immediately. Layer 2 (CEO confirm): parseStructuredActions() on %%ACTIONS%% signal in CEO response, with regex fallback. Deduplicates against Layer 1. Also adds: canned openers for common messages (hybrid with real streaming), message action metadata bars, cleanAgentMessage strips action signals, generate-artifact now posts CEO comment and updates task to in_review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8abbc48c71
commit
9bc683b17b
2 changed files with 347 additions and 96 deletions
|
|
@ -16,13 +16,33 @@ import { notFound } from "../errors.js";
|
||||||
import { parseObject } from "../adapters/utils.js";
|
import { parseObject } from "../adapters/utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if the CEO's response commits to creating an artifact.
|
* Parse structured action signals from CEO response.
|
||||||
* Returns a list of artifacts to create. Simple pattern matching —
|
* CEO is prompted to include %%ACTIONS%%{...}%%/ACTIONS%% at the end of each response.
|
||||||
* reliable and instant, no AI call needed.
|
* Falls back to regex pattern matching if no structured signal found.
|
||||||
|
*/
|
||||||
|
function parseStructuredActions(response: string): {
|
||||||
|
artifacts: Array<{ title: string; type?: string }>;
|
||||||
|
tasks: Array<{ title: string; assignTo?: string }>;
|
||||||
|
} | null {
|
||||||
|
const match = response.match(/%%ACTIONS%%([\s\S]*?)%%\/ACTIONS%%/);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[1].trim());
|
||||||
|
return {
|
||||||
|
artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
|
||||||
|
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: detect artifact commitments via regex pattern matching.
|
||||||
|
* Used when CEO doesn't output structured signal.
|
||||||
*/
|
*/
|
||||||
function detectArtifactCommitments(response: string): Array<{ title: string; status: string }> {
|
function detectArtifactCommitments(response: string): Array<{ title: string; status: string }> {
|
||||||
const artifacts: Array<{ title: string; status: string }> = [];
|
const artifacts: Array<{ title: string; status: string }> = [];
|
||||||
const lower = response.toLowerCase();
|
|
||||||
|
|
||||||
// Hiring plan commitment
|
// Hiring plan commitment
|
||||||
if (
|
if (
|
||||||
|
|
@ -44,6 +64,13 @@ function detectArtifactCommitments(response: string): Array<{ title: string; sta
|
||||||
return artifacts;
|
return artifacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip structured action signals from response text before persisting.
|
||||||
|
*/
|
||||||
|
function stripActionSignals(response: string): string {
|
||||||
|
return response.replace(/%%ACTIONS%%[\s\S]*?%%\/ACTIONS%%/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat relay endpoint — calls the adapter directly and streams the response
|
* Chat relay endpoint — calls the adapter directly and streams the response
|
||||||
* back via SSE. Bypasses the heartbeat queue for real-time conversation.
|
* back via SSE. Bypasses the heartbeat queue for real-time conversation.
|
||||||
|
|
@ -344,6 +371,23 @@ Write the "${artifactTitle}" now. Be specific, actionable, and thorough. Use mar
|
||||||
})
|
})
|
||||||
.where(eq(issueWorkProducts.id, workProductId));
|
.where(eq(issueWorkProducts.id, workProductId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update task to in_review and reassign to board
|
||||||
|
try {
|
||||||
|
await issueSvc.update(taskId, {
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
// assigneeUserId will be set by the frontend or left for inbox to pick up
|
||||||
|
});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
|
||||||
|
// Post CEO notification in chat
|
||||||
|
try {
|
||||||
|
await issueSvc.addComment(taskId,
|
||||||
|
`The **${artifactTitle}** is ready for your review. Take a look in the Artifacts panel when you're ready.`,
|
||||||
|
{ agentId: agent.id },
|
||||||
|
);
|
||||||
|
} catch { /* best effort */ }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[generate-artifact] failed:", err);
|
console.error("[generate-artifact] failed:", err);
|
||||||
}
|
}
|
||||||
|
|
@ -398,10 +442,21 @@ Write the "${artifactTitle}" now. Be specific, actionable, and thorough. Use mar
|
||||||
let systemPrompt = `You are ${agent.name}, the CEO of this company. The user is the board of directors.
|
let systemPrompt = `You are ${agent.name}, the CEO of this company. The user is the board of directors.
|
||||||
|
|
||||||
IMPORTANT RULES:
|
IMPORTANT RULES:
|
||||||
- Be conversational, strategic, and concise.
|
- Be conversational, strategic, and concise. Keep responses to 1-3 sentences for action acknowledgments.
|
||||||
- When the board asks you to create something (a hiring plan, strategy doc, etc.), respond with a SHORT acknowledgment (1-2 sentences max). Do NOT write the full document in chat. Just confirm you'll start working on it. The system will handle document creation separately.
|
- Be biased for action. When the board asks you to create something, confirm immediately in ONE sentence. Do NOT write the full document in chat. The system handles document creation separately.
|
||||||
- When discussing strategy, priorities, or giving advice, be thorough and helpful.
|
- When discussing strategy or giving advice, be helpful but brief. Ask clarifying questions if needed, but don't over-discuss — drive toward creating a task.
|
||||||
- Never reference tools, files, code, or technical systems. You are a CEO, not an engineer.`;
|
- Never reference tools, files, code, or technical systems. You are a CEO, not an engineer.
|
||||||
|
- When creating plans that involve hiring, default to AI agents unless the board explicitly specifies human roles.
|
||||||
|
|
||||||
|
STRUCTURED SIGNAL (REQUIRED):
|
||||||
|
At the END of every response, on its own line, output an action signal:
|
||||||
|
%%ACTIONS%%{"tasks":[],"artifacts":[]}%%/ACTIONS%%
|
||||||
|
|
||||||
|
If you are committing to create something, populate the arrays:
|
||||||
|
- artifacts: [{"title":"Hiring Plan","type":"document"}]
|
||||||
|
- tasks: [{"title":"Build landing page","assignTo":"engineer"}]
|
||||||
|
|
||||||
|
If nothing to create, output empty arrays. ALWAYS include this signal line.`;
|
||||||
const instructionsPath = (config as any).instructionsFilePath;
|
const instructionsPath = (config as any).instructionsFilePath;
|
||||||
if (instructionsPath && typeof instructionsPath === "string") {
|
if (instructionsPath && typeof instructionsPath === "string") {
|
||||||
try {
|
try {
|
||||||
|
|
@ -499,15 +554,40 @@ IMPORTANT RULES:
|
||||||
proc.on("close", async (exitCode) => {
|
proc.on("close", async (exitCode) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
// Save full response as agent comment
|
// Parse structured actions before stripping
|
||||||
if (fullResponse.trim()) {
|
const structuredActions = parseStructuredActions(fullResponse);
|
||||||
|
|
||||||
|
// Strip action signals before persisting
|
||||||
|
const cleanedResponse = stripActionSignals(fullResponse);
|
||||||
|
|
||||||
|
// Save cleaned response as agent comment
|
||||||
|
if (cleanedResponse) {
|
||||||
try {
|
try {
|
||||||
await issueSvc.addComment(taskId, fullResponse.trim(), {
|
await issueSvc.addComment(taskId, cleanedResponse, {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
});
|
});
|
||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send observer event with detected actions (Layer 2)
|
||||||
|
if (structuredActions && (structuredActions.artifacts.length > 0 || structuredActions.tasks.length > 0)) {
|
||||||
|
if (res.writable) {
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
type: "observer",
|
||||||
|
actions: {
|
||||||
|
artifacts: structuredActions.artifacts.map((a) => ({ title: a.title, status: "in_progress" })),
|
||||||
|
tasks: structuredActions.tasks,
|
||||||
|
},
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: regex-based detection
|
||||||
|
const artifacts = detectArtifactCommitments(fullResponse);
|
||||||
|
if (artifacts.length > 0 && res.writable) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: "observer", actions: { artifacts, tasks: [] } })}\n\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send completion event
|
// Send completion event
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
if (res.writable) {
|
if (res.writable) {
|
||||||
|
|
@ -520,12 +600,6 @@ IMPORTANT RULES:
|
||||||
})}\n\n`,
|
})}\n\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if the CEO committed to creating an artifact
|
|
||||||
const artifacts = detectArtifactCommitments(fullResponse);
|
|
||||||
if (artifacts.length > 0 && res.writable) {
|
|
||||||
res.write(`data: ${JSON.stringify({ type: "observer", actions: { artifacts, tasks: [] } })}\n\n`);
|
|
||||||
}
|
|
||||||
if (res.writable) res.end();
|
if (res.writable) res.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,14 @@ interface CEOChatPanelProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean agent message content — strip system init JSON, code blocks with
|
* Clean agent message content — strip system init JSON, code blocks with
|
||||||
* raw config/tool dumps, and other non-conversational output.
|
* raw config/tool dumps, structured signals, and other non-conversational output.
|
||||||
*/
|
*/
|
||||||
function cleanAgentMessage(body: string): string {
|
function cleanAgentMessage(body: string): string {
|
||||||
let cleaned = body;
|
let cleaned = body;
|
||||||
|
|
||||||
|
// Strip structured action signals
|
||||||
|
cleaned = cleaned.replace(/%%ACTIONS%%[\s\S]*?%%\/ACTIONS%%/g, "");
|
||||||
|
|
||||||
// Remove markdown links
|
// Remove markdown links
|
||||||
cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
||||||
|
|
||||||
|
|
@ -65,6 +68,74 @@ function cleanAgentMessage(body: string): string {
|
||||||
return cleaned.trim();
|
return cleaned.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern-match user input to return an instant canned CEO opener.
|
||||||
|
* Returns null if no pattern matches — fall through to pure streaming.
|
||||||
|
*/
|
||||||
|
function getCannedOpener(message: string): string | null {
|
||||||
|
const lower = message.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Greetings
|
||||||
|
if (/^(hi|hello|hey|howdy|sup|what's up|yo)\b/.test(lower)) {
|
||||||
|
return "Great to have you here! I've been reviewing our mission. What would you like to tackle first \u2014 building the team, or mapping out strategy?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiring plan / team building
|
||||||
|
if (/hiring\s*plan|build.*team|hire|team\s*plan|staffing/.test(lower)) {
|
||||||
|
return "On it. I'll start drafting a hiring plan tailored to our mission right now.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy / roadmap
|
||||||
|
if (/strateg|roadmap|priorities|game\s*plan/.test(lower)) {
|
||||||
|
return "Good call. Let me pull together a strategic brief based on our goals.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic "build/create/draft X"
|
||||||
|
const buildMatch = lower.match(/(?:build|create|draft|write|make|start|set up)\s+(?:a\s+|an\s+|the\s+)?(.+)/);
|
||||||
|
if (buildMatch) {
|
||||||
|
return "Got it. I'll get that started and have something for you to review shortly.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 1 observer: detect actionable intent from the user's message.
|
||||||
|
* Returns task/artifact info to create immediately, before the CEO responds.
|
||||||
|
*/
|
||||||
|
function detectUserIntent(message: string): { taskTitle: string; artifactTitle: string } | null {
|
||||||
|
const lower = message.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Hiring plan
|
||||||
|
if (/hiring\s*plan|build.*team|team\s*plan|staffing\s*plan/.test(lower)) {
|
||||||
|
return { taskTitle: "Create hiring plan", artifactTitle: "Hiring Plan" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy
|
||||||
|
if (/strateg(?:y|ic)\s*(?:doc|document|plan|brief)?|roadmap/.test(lower)) {
|
||||||
|
return { taskTitle: "Create strategy document", artifactTitle: "Strategy Document" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic "build/create X"
|
||||||
|
const buildMatch = lower.match(/(?:build|create|draft|write|make)\s+(?:a\s+|an\s+|the\s+)?(.+?)(?:\s+for\s+|\s+about\s+|$)/);
|
||||||
|
if (buildMatch) {
|
||||||
|
const thing = buildMatch[1].replace(/[.!?]+$/, "").trim();
|
||||||
|
if (thing.length > 2 && thing.length < 60) {
|
||||||
|
const title = thing.split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||||
|
return { taskTitle: `Create ${thing}`, artifactTitle: title };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata about actions triggered by a message */
|
||||||
|
interface MessageAction {
|
||||||
|
taskId?: string;
|
||||||
|
taskTitle?: string;
|
||||||
|
artifacts?: Array<{ title: string; status: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a streaming chunk looks like system/init output rather than
|
* Check if a streaming chunk looks like system/init output rather than
|
||||||
* conversational text. Used to filter relay streaming.
|
* conversational text. Used to filter relay streaming.
|
||||||
|
|
@ -169,12 +240,12 @@ function getSuggestionChips(
|
||||||
if (hasComments) {
|
if (hasComments) {
|
||||||
return [
|
return [
|
||||||
"What should we prioritize?",
|
"What should we prioritize?",
|
||||||
"Create a new project",
|
"Build a hiring plan",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
"Build a hiring plan",
|
||||||
"Let's talk strategy",
|
"Let's talk strategy",
|
||||||
"What do you need from me?",
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +282,8 @@ export function CEOChatPanel({
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
// Track whether we've already created a draft artifact in the current send cycle
|
// Track whether we've already created a draft artifact in the current send cycle
|
||||||
const draftCreatedRef = useRef(false);
|
const draftCreatedRef = useRef(false);
|
||||||
|
// Track actions (tasks/artifacts) associated with messages
|
||||||
|
const [messageActions, setMessageActions] = useState<Map<number, MessageAction>>(new Map());
|
||||||
|
|
||||||
// Poll comments — faster when waiting for a response
|
// Poll comments — faster when waiting for a response
|
||||||
const { data: rawComments, isLoading } = useQuery({
|
const { data: rawComments, isLoading } = useQuery({
|
||||||
|
|
@ -321,14 +394,86 @@ export function CEOChatPanel({
|
||||||
}
|
}
|
||||||
}, [comments?.length, streamingText]);
|
}, [comments?.length, streamingText]);
|
||||||
|
|
||||||
// Send message — try streaming relay first, fall back to poll-based
|
// Build conversation context string from comments (used for artifact generation)
|
||||||
|
const buildConvoContext = useCallback(() => {
|
||||||
|
return comments?.map((c) => {
|
||||||
|
const role = c.authorAgentId ? "CEO" : "USER";
|
||||||
|
return `${role}: ${c.body}`;
|
||||||
|
}).join("\n\n") ?? "";
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
// Handle observer actions (Layer 2) — create tasks/artifacts if not already created by Layer 1
|
||||||
|
const handleObserverActions = useCallback(async (
|
||||||
|
actions: { artifacts?: Array<{ title: string; status: string }>; tasks?: Array<{ title: string; description?: string }> },
|
||||||
|
messageIndex: number,
|
||||||
|
) => {
|
||||||
|
const convoContext = buildConvoContext();
|
||||||
|
|
||||||
|
for (const artifact of actions.artifacts ?? []) {
|
||||||
|
// Dedup: skip if Layer 1 already created this artifact
|
||||||
|
if (draftCreatedRef.current) continue;
|
||||||
|
draftCreatedRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wp = await issuesApi.createWorkProduct(taskId, {
|
||||||
|
type: "document",
|
||||||
|
title: artifact.title,
|
||||||
|
provider: "paperclip",
|
||||||
|
status: "draft",
|
||||||
|
reviewState: "none",
|
||||||
|
isPrimary: true,
|
||||||
|
summary: `${agentName} is working on ${artifact.title}...`,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
|
||||||
|
|
||||||
|
// Update message actions metadata
|
||||||
|
setMessageActions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(messageIndex) ?? {};
|
||||||
|
next.set(messageIndex, {
|
||||||
|
...existing,
|
||||||
|
artifacts: [...(existing.artifacts ?? []), { title: artifact.title, status: "generating" }],
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire background generation
|
||||||
|
fetch(`/api/agents/${agentId}/chat/generate-artifact`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
taskId,
|
||||||
|
artifactTitle: artifact.title,
|
||||||
|
workProductId: (wp as any).id,
|
||||||
|
conversationContext: convoContext,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Assign task to CEO
|
||||||
|
issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }).catch(() => {});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of actions.tasks ?? []) {
|
||||||
|
try {
|
||||||
|
await issuesApi.create(companyId, {
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}, [taskId, agentId, companyId, agentName, queryClient, buildConvoContext]);
|
||||||
|
|
||||||
|
// Send message — canned+stream hybrid with two-layer observer
|
||||||
const sendMessage = useCallback(async (body: string) => {
|
const sendMessage = useCallback(async (body: string) => {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed || sending) return;
|
if (!trimmed || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setInput("");
|
setInput("");
|
||||||
setOptimisticMessage(trimmed);
|
setOptimisticMessage(trimmed);
|
||||||
setOptimisticTyping(true);
|
|
||||||
commentCountAtSendRef.current = comments?.length ?? 0;
|
commentCountAtSendRef.current = comments?.length ?? 0;
|
||||||
draftCreatedRef.current = false;
|
draftCreatedRef.current = false;
|
||||||
|
|
||||||
|
|
@ -336,8 +481,72 @@ export function CEOChatPanel({
|
||||||
setIgnoreBeforeCommentId(latestId);
|
setIgnoreBeforeCommentId(latestId);
|
||||||
setDetectedPlanCommentId(null);
|
setDetectedPlanCommentId(null);
|
||||||
|
|
||||||
|
const messageIndex = (comments?.length ?? 0) + 1; // Index for the CEO's response message
|
||||||
|
|
||||||
|
// --- Layer 1: Instant user intent detection ---
|
||||||
|
const intent = detectUserIntent(trimmed);
|
||||||
|
if (intent) {
|
||||||
|
draftCreatedRef.current = true;
|
||||||
|
// Create task + work product immediately
|
||||||
|
try {
|
||||||
|
const wp = await issuesApi.createWorkProduct(taskId, {
|
||||||
|
type: "document",
|
||||||
|
title: intent.artifactTitle,
|
||||||
|
provider: "paperclip",
|
||||||
|
status: "draft",
|
||||||
|
reviewState: "none",
|
||||||
|
isPrimary: true,
|
||||||
|
summary: `${agentName} is working on ${intent.artifactTitle}...`,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
|
||||||
|
|
||||||
|
// Set message actions metadata
|
||||||
|
setMessageActions((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(messageIndex, {
|
||||||
|
taskTitle: intent.taskTitle,
|
||||||
|
artifacts: [{ title: intent.artifactTitle, status: "generating" }],
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire background artifact generation
|
||||||
|
const convoContext = buildConvoContext() + `\n\nUSER: ${trimmed}`;
|
||||||
|
fetch(`/api/agents/${agentId}/chat/generate-artifact`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
taskId,
|
||||||
|
artifactTitle: intent.artifactTitle,
|
||||||
|
workProductId: (wp as any).id,
|
||||||
|
conversationContext: convoContext,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Update task status
|
||||||
|
issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }).catch(() => {});
|
||||||
|
} catch { /* best effort — proceed with chat */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Canned + Stream hybrid ---
|
||||||
|
const cannedText = getCannedOpener(trimmed);
|
||||||
|
|
||||||
|
// Initialize streaming buffer
|
||||||
|
setStreamingText("");
|
||||||
|
streamingBufferRef.current = "";
|
||||||
|
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
||||||
|
|
||||||
|
if (cannedText) {
|
||||||
|
// Start typewriter with canned text immediately — no typing indicator
|
||||||
|
setOptimisticTyping(false);
|
||||||
|
streamingBufferRef.current = cannedText;
|
||||||
|
setStreamingText(cannedText.slice(0, 1)); // Kick typewriter
|
||||||
|
} else {
|
||||||
|
// No canned match — show typing indicator until first chunk
|
||||||
|
setOptimisticTyping(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try lightweight streaming endpoint (longer timeout — CLI needs startup time)
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const fetchTimeout = setTimeout(() => controller.abort(), 60000);
|
const fetchTimeout = setTimeout(() => controller.abort(), 60000);
|
||||||
const res = await fetch(`/api/agents/${agentId}/chat/stream`, {
|
const res = await fetch(`/api/agents/${agentId}/chat/stream`, {
|
||||||
|
|
@ -352,10 +561,6 @@ export function CEOChatPanel({
|
||||||
throw new Error("Relay not available");
|
throw new Error("Relay not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
setStreamingText("");
|
|
||||||
streamingBufferRef.current = "";
|
|
||||||
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
|
|
@ -373,99 +578,49 @@ export function CEOChatPanel({
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(line.slice(6));
|
const event = JSON.parse(line.slice(6));
|
||||||
if (event.type === "chunk" && !isSystemChunk(event.text)) {
|
if (event.type === "chunk" && !isSystemChunk(event.text)) {
|
||||||
// Clear typing indicator on first real chunk
|
|
||||||
setOptimisticTyping(false);
|
setOptimisticTyping(false);
|
||||||
// Add to buffer — typewriter effect will reveal progressively
|
if (cannedText) {
|
||||||
streamingBufferRef.current += event.text;
|
// Append real stream after canned text with a space separator
|
||||||
// Kick the typewriter if it hasn't started
|
const separator = streamingBufferRef.current.length === cannedText.length ? " " : "";
|
||||||
|
streamingBufferRef.current += separator + event.text;
|
||||||
|
} else {
|
||||||
|
streamingBufferRef.current += event.text;
|
||||||
|
}
|
||||||
|
// Kick typewriter if not started
|
||||||
setStreamingText((prev) => prev || streamingBufferRef.current.slice(0, 1));
|
setStreamingText((prev) => prev || streamingBufferRef.current.slice(0, 1));
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
// Flush remaining buffer instantly
|
// Flush remaining buffer
|
||||||
setStreamingText(streamingBufferRef.current);
|
setStreamingText(streamingBufferRef.current);
|
||||||
if (streamingTimerRef.current) clearInterval(streamingTimerRef.current);
|
if (streamingTimerRef.current) clearInterval(streamingTimerRef.current);
|
||||||
streamingTimerRef.current = null;
|
streamingTimerRef.current = null;
|
||||||
// Refresh comments to pick up persisted messages
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: queryKeys.issues.comments(taskId),
|
|
||||||
});
|
|
||||||
} else if (event.type === "observer" && event.actions) {
|
} else if (event.type === "observer" && event.actions) {
|
||||||
// Observer agent detected artifacts or tasks to create
|
// Layer 2: CEO structured signal — create tasks/artifacts if Layer 1 didn't
|
||||||
const actions = event.actions as {
|
handleObserverActions(event.actions, messageIndex);
|
||||||
artifacts?: Array<{ title: string; status: string }>;
|
|
||||||
tasks?: Array<{ title: string; description: string }>;
|
|
||||||
};
|
|
||||||
// Build conversation context for artifact generation
|
|
||||||
const convoContext = comments?.map((c) => {
|
|
||||||
const role = c.authorAgentId ? "CEO" : "USER";
|
|
||||||
return `${role}: ${c.body}`;
|
|
||||||
}).join("\n\n") ?? "";
|
|
||||||
|
|
||||||
for (const artifact of actions.artifacts ?? []) {
|
|
||||||
issuesApi.createWorkProduct(taskId, {
|
|
||||||
type: "document",
|
|
||||||
title: artifact.title,
|
|
||||||
provider: "paperclip",
|
|
||||||
status: "draft",
|
|
||||||
reviewState: "none",
|
|
||||||
isPrimary: true,
|
|
||||||
summary: `${agentName} is working on ${artifact.title}...`,
|
|
||||||
}).then((wp) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
|
|
||||||
// Trigger background document generation
|
|
||||||
fetch(`/api/agents/${agentId}/chat/generate-artifact`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
taskId,
|
|
||||||
artifactTitle: artifact.title,
|
|
||||||
workProductId: (wp as any).id,
|
|
||||||
conversationContext: convoContext,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
}).catch(() => {});
|
|
||||||
// Assign task to CEO
|
|
||||||
issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }).catch(() => {});
|
|
||||||
}
|
|
||||||
for (const task of actions.tasks ?? []) {
|
|
||||||
issuesApi.create(companyId, {
|
|
||||||
title: task.title,
|
|
||||||
description: task.description,
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
status: "todo",
|
|
||||||
}).then(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "error") {
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
streamingBufferRef.current = "";
|
streamingBufferRef.current = "";
|
||||||
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
||||||
}
|
}
|
||||||
} catch { /* malformed SSE line, skip */ }
|
} catch { /* malformed SSE line */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait briefly for typewriter to finish, then clear
|
// Brief delay for typewriter to finish, then clear
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
streamingBufferRef.current = "";
|
streamingBufferRef.current = "";
|
||||||
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
||||||
}, 500);
|
}, 500);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
|
||||||
queryKey: queryKeys.issues.comments(taskId),
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Stream endpoint failed or timed out — message was already saved server-side,
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
|
||||||
// so just refresh comments and let polling pick up any response
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: queryKeys.issues.comments(taskId),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
setOptimisticTyping(false);
|
setOptimisticTyping(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [sending, taskId, agentId, companyId, agentName, queryClient, comments]);
|
}, [sending, taskId, agentId, companyId, agentName, queryClient, comments, buildConvoContext, handleObserverActions]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
sendMessage(input);
|
sendMessage(input);
|
||||||
|
|
@ -636,11 +791,12 @@ export function CEOChatPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{comments?.map((comment) => {
|
{comments?.map((comment, idx) => {
|
||||||
const isAgent = Boolean(comment.authorAgentId);
|
const isAgent = Boolean(comment.authorAgentId);
|
||||||
// Hide comments that are entirely system output
|
// Hide comments that are entirely system output
|
||||||
const displayBody = isAgent ? cleanAgentMessage(comment.body) : comment.body;
|
const displayBody = isAgent ? cleanAgentMessage(comment.body) : comment.body;
|
||||||
if (isAgent && !displayBody) return null;
|
if (isAgent && !displayBody) return null;
|
||||||
|
const actions = isAgent ? messageActions.get(idx) : undefined;
|
||||||
return (
|
return (
|
||||||
<div key={comment.id}>
|
<div key={comment.id}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -665,7 +821,28 @@ export function CEOChatPanel({
|
||||||
<MarkdownBody>{displayBody}</MarkdownBody>
|
<MarkdownBody>{displayBody}</MarkdownBody>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Task/artifact metadata bar */}
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2 mt-1 ml-1 text-[11px] text-muted-foreground">
|
||||||
|
{actions.taskTitle && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-cyan-500" />
|
||||||
|
Task created
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{actions.artifacts?.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.title}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onOpenArtifact?.(a.title.toLowerCase().replace(/\s+/g, "-"), a.title)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground/60">·</span>
|
||||||
|
<Loader2 className={cn("h-3 w-3", a.status === "generating" ? "animate-spin text-cyan-500" : "text-green-500")} />
|
||||||
|
{a.title} — {a.status === "generating" ? "generating..." : "ready for review"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue