Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion packages/archioffice-agents/src/client/AgentConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { IconArrowLeft, IconRobot, IconChevronDown } from '@tabler/icons-react';
import { apiFetch } from '@/src/lib/api';
import type { Agent, AgentContextScope } from '../types.js';
import type { Agent, AgentContextScope, AgentActionScope } from '../types.js';
import { AGENT_RESOURCES } from '../types.js';

const AVATAR_COLORS = ['#206bc4', '#2fb344', '#f76707', '#ae3ec9', '#1098ad', '#e8590c'];

Expand All @@ -15,6 +16,10 @@ const SCOPES: { key: AgentContextScope; label: string }[] = [
{ key: 'tasks', label: 'Tâches' },
];

// One toggle per resource grants that resource's full create/update/delete
// surface — the same surface a human has in that section of the app.
const ALL_ACTION_KEYS: AgentActionScope[] = AGENT_RESOURCES.map(r => r.key);

export default function AgentConfig() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
Expand All @@ -33,6 +38,7 @@ export default function AgentConfig() {
const [tone, setTone] = useState('');
const [directives, setDirectives] = useState('');
const [contextScopes, setContextScopes] = useState<AgentContextScope[]>([]);
const [actionScopes, setActionScopes] = useState<AgentActionScope[]>([]);
const [systemPromptOverride, setSystemPromptOverride] = useState('');

useEffect(() => {
Expand All @@ -48,6 +54,7 @@ export default function AgentConfig() {
setTone(found.tone ?? '');
setDirectives(found.directives ?? '');
setContextScopes(found.context_scopes as AgentContextScope[]);
setActionScopes((found.action_scopes ?? []) as AgentActionScope[]);
setSystemPromptOverride(found.system_prompt_override ?? '');
})
.finally(() => setLoading(false));
Expand All @@ -59,6 +66,17 @@ export default function AgentConfig() {
);
};

const toggleActionScope = (scope: AgentActionScope) => {
setActionScopes((prev: AgentActionScope[]) =>
prev.includes(scope) ? prev.filter((s: AgentActionScope) => s !== scope) : [...prev, scope]
);
};

const allActionsSelected = ALL_ACTION_KEYS.every(k => actionScopes.includes(k));
const toggleAllActionScopes = () => {
setActionScopes(allActionsSelected ? [] : [...ALL_ACTION_KEYS]);
};

const handleSave = async () => {
if (!agent) return;
setSaving(true);
Expand All @@ -69,6 +87,7 @@ export default function AgentConfig() {
name, role_title: roleTitle, avatar_initials: avatarInitials,
avatar_color: avatarColor, tone, directives,
context_scopes: contextScopes,
action_scopes: actionScopes,
system_prompt_override: systemPromptOverride || null,
}),
});
Expand Down Expand Up @@ -191,6 +210,35 @@ export default function AgentConfig() {
</div>
</section>

{/* Actions — write permissions */}
<section className="p-5 rounded-xl border space-y-3" style={{ background: 'var(--tblr-surface)', borderColor: 'var(--tblr-border)' }}>
<div className="flex items-center justify-between gap-3">
<h2 className="font-semibold text-[14px]" style={{ color: 'var(--tblr-text)' }}>{t('agent_config_actions')}</h2>
<button
onClick={toggleAllActionScopes}
className="text-[12px] font-medium hover:underline shrink-0"
style={{ color: 'var(--tblr-primary)' }}
>
{allActionsSelected ? t('agent_config_actions_clear_all') : t('agent_config_actions_select_all')}
</button>
</div>
<p className="text-[11px]" style={{ color: 'var(--tblr-muted)' }}>{t('agent_config_actions_hint')}</p>
<div className="space-y-2">
{AGENT_RESOURCES.map(scope => (
<label key={scope.key} className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={actionScopes.includes(scope.key)}
onChange={() => toggleActionScope(scope.key)}
className="w-4 h-4 rounded"
style={{ accentColor: 'var(--tblr-primary)' }}
/>
<span className="text-[13px]" style={{ color: 'var(--tblr-text)' }}>{scope.label}</span>
</label>
))}
</div>
</section>

{/* Advanced prompt */}
<section className="p-5 rounded-xl border space-y-3" style={{ background: 'var(--tblr-surface)', borderColor: 'var(--tblr-border)' }}>
<button
Expand Down
64 changes: 52 additions & 12 deletions packages/archioffice-agents/src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AgentRow } from '../types.js';
import { buildAgentSystemPrompt } from './systemPrompts.js';
import { buildAgentContext } from './context.js';
import { parseArtifactFromText, generateArtifact } from './artifacts.js';
import { buildAgentTools, executeAgentAction } from './tools.js';

type GetTenantId = (userId: string) => Promise<string>;
type GetTenantPlan = (tenantId: string) => Promise<{ plan: string; trial_ends_at: string | null; is_expired: boolean }>;
Expand All @@ -17,6 +18,11 @@ interface BillingHelpers {
deductAiCredit: DeductAiCreditFn;
maybeRefreshMonthlyCredits: (tenantId: string, plan: string) => Promise<void>;
PLAN_AI_MONTHLY_CREDIT_CENTS: Record<string, number>;
// Base URL the agent action tools call back into (e.g. http://127.0.0.1:PORT)
// — actions execute through the app's own REST API, forwarding the
// caller's auth token, so they run through the exact same validation and
// side effects as a human using the UI.
baseUrl: string;
}

export function registerAgentRoutes(
Expand Down Expand Up @@ -50,18 +56,20 @@ export function registerAgentRoutes(
app.post('/api/agents', async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { slug, name, role_title, avatar_initials, avatar_color, tone, directives, context_scopes, system_prompt_override, from_template_id } = req.body;
const { slug, name, role_title, avatar_initials, avatar_color, tone, directives, context_scopes, action_scopes, system_prompt_override, from_template_id } = req.body;

let agentData: any = { tenant_id: tenantId };

if (from_template_id) {
const { data: template, error: tErr } = await supabaseAdmin.from('agents').select('*').eq('id', from_template_id).is('tenant_id', null).single();
if (tErr || !template) return res.status(404).json({ error: 'Template not found' });
const t = template as any;
agentData = { ...agentData, slug: t.slug, name: t.name, role_title: t.role_title, avatar_initials: t.avatar_initials, avatar_color: t.avatar_color, tone: t.tone, directives: t.directives, context_scopes: t.context_scopes, is_active: true, is_system_template: false };
// Write permissions are never inherited from a template — the tenant
// must opt in explicitly per activated agent.
agentData = { ...agentData, slug: t.slug, name: t.name, role_title: t.role_title, avatar_initials: t.avatar_initials, avatar_color: t.avatar_color, tone: t.tone, directives: t.directives, context_scopes: t.context_scopes, action_scopes: [], is_active: true, is_system_template: false };
} else {
if (!slug || !name || !role_title) return res.status(400).json({ error: 'slug, name, role_title required' });
agentData = { ...agentData, slug, name, role_title, avatar_initials: avatar_initials || 'AI', avatar_color: avatar_color || '#206bc4', tone, directives, context_scopes: context_scopes || [], system_prompt_override, is_active: true, is_system_template: false };
agentData = { ...agentData, slug, name, role_title, avatar_initials: avatar_initials || 'AI', avatar_color: avatar_color || '#206bc4', tone, directives, context_scopes: context_scopes || [], action_scopes: action_scopes || [], system_prompt_override, is_active: true, is_system_template: false };
}

const { data, error } = await supabaseAdmin.from('agents').insert(agentData).select().single();
Expand All @@ -75,8 +83,8 @@ export function registerAgentRoutes(
try {
const tenantId = await getTenantId(req.user.id);
const { id } = req.params;
const { name, role_title, avatar_initials, avatar_color, tone, directives, context_scopes, system_prompt_override, is_active } = req.body;
const { data, error } = await supabaseAdmin.from('agents').update({ name, role_title, avatar_initials, avatar_color, tone, directives, context_scopes, system_prompt_override, is_active }).eq('id', id).eq('tenant_id', tenantId).select().single();
const { name, role_title, avatar_initials, avatar_color, tone, directives, context_scopes, action_scopes, system_prompt_override, is_active } = req.body;
const { data, error } = await supabaseAdmin.from('agents').update({ name, role_title, avatar_initials, avatar_color, tone, directives, context_scopes, action_scopes, system_prompt_override, is_active }).eq('id', id).eq('tenant_id', tenantId).select().single();
if (error) throw error;
res.json(data);
} catch (e: any) { res.status(500).json({ error: e.message }); }
Expand Down Expand Up @@ -201,28 +209,60 @@ export function registerAgentRoutes(
parts: [{ text: m.content }],
}));

const actionScopes: string[] = (agent as any).action_scopes || [];
const tools = buildAgentTools(actionScopes);

const chat = genai.chats.create({
model: 'gemini-3-flash-preview',
config: { systemInstruction: systemPrompt },
config: {
systemInstruction: systemPrompt,
...(tools.length > 0 ? { tools: [{ functionDeclarations: tools as any }] } : {}),
},
history: geminiHistory,
});
// Bounds how long a stuck/slow Gemini call can hold the request open —
// shorter than the client's own abort timeout so the client always gets
// this explicit message instead of a silent connection drop.
const AGENT_CHAT_TIMEOUT_MS = 55000;
const result = await Promise.race([
chat.sendMessage({ message }),
const withTimeout = <T>(p: Promise<T>): Promise<T> => Promise.race([
p,
new Promise<never>((_, reject) =>
setTimeout(() => reject(Object.assign(new Error("L'agent IA n'a pas répondu à temps."), { code: 'AGENT_TIMEOUT' })), AGENT_CHAT_TIMEOUT_MS)
),
]);
const rawText = result.text ?? '';

const inputTokens = (result as any).usageMetadata?.promptTokenCount ?? 0;
const outputTokens = (result as any).usageMetadata?.candidatesTokenCount ?? 0;
let result = await withTimeout(chat.sendMessage({ message }));
let inputTokens = (result as any).usageMetadata?.promptTokenCount ?? 0;
let outputTokens = (result as any).usageMetadata?.candidatesTokenCount ?? 0;

// Function-calling loop: the model may chain several tool calls (e.g.
// create_contact then create_proposal with the returned id) before it
// produces a final natural-language reply.
const actionSummaries: string[] = [];
const MAX_FUNCTION_ROUNDS = 4;
let round = 0;
if (tools.length > 0 && billing?.baseUrl) {
const authHeader = req.headers.authorization as string | undefined;
while (result.functionCalls && result.functionCalls.length > 0 && round < MAX_FUNCTION_ROUNDS) {
round++;
const responseParts: { functionResponse: { name?: string; response: Record<string, unknown> } }[] = [];
for (const call of result.functionCalls) {
const { response, summary } = await executeAgentAction(billing.baseUrl, authHeader, actionScopes, call);
if (summary) actionSummaries.push(summary);
responseParts.push({ functionResponse: { name: call.name, response } });
}
result = await withTimeout(chat.sendMessage({ message: responseParts }));
inputTokens += (result as any).usageMetadata?.promptTokenCount ?? 0;
outputTokens += (result as any).usageMetadata?.candidatesTokenCount ?? 0;
}
}

const rawText = result.text ?? '';

const { cleanText, spec } = parseArtifactFromText(rawText);
const reply = cleanText;
const reply = actionSummaries.length > 0
? actionSummaries.map(s => `✅ ${s}`).join('\n') + '\n\n' + cleanText
: cleanText;
const artifact = spec ? generateArtifact(spec) : undefined;

let newBalance = balance;
Expand Down
22 changes: 20 additions & 2 deletions packages/archioffice-agents/src/server/systemPrompts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentRow, AgentContext } from '../types.js';
import { describeAuthorizedResources } from './tools.js';

export function buildAgentSystemPrompt(agent: AgentRow, ctx: AgentContext): string {
if (agent.system_prompt_override) {
Expand Down Expand Up @@ -33,6 +34,21 @@ export function buildAgentSystemPrompt(agent: AgentRow, ctx: AgentContext): stri
ctx.documentContents.map(d => `\n--- ${d.name} ---\n${d.content}\n---`).join('\n')
: '';

const actionScopes = agent.action_scopes || [];
const canAct = actionScopes.length > 0;
const resourceSchema = describeAuthorizedResources(actionScopes);
const actionsSection = canAct
? `\n═══ SCHÉMA DES RESSOURCES AUTORISÉES ═══
Tu peux utiliser create_record / update_record / delete_record sur les ressources suivantes (champs suivis d'un * = obligatoires) :
${resourceSchema}

Règles :
1. Une action de création ou modification doit toujours être suivie d'une confirmation claire à l'utilisateur (quoi, sur quelle ressource, avec quel identifiant/référence si connu).
2. Ne prétends jamais avoir créé/modifié/supprimé quelque chose sans avoir réellement appelé l'outil correspondant.
3. Ne supprime (delete_record) que sur demande explicite et non ambiguë portant sur un enregistrement précis.
4. Si une ressource nécessaire n'est pas dans la liste ci-dessus, dis-le à l'utilisateur au lieu d'improviser.\n`
: '';

return `Tu es ${agent.name}, ${agent.role_title} du cabinet d'architecture "${ctx.tenantName}".
Date du jour : ${ctx.currentDate}.
Tu réponds à : ${ctx.currentUserName}.
Expand All @@ -48,10 +64,12 @@ ${agent.directives || 'Être précis et factuel. Ne jamais inventer de données.
✓ Lire et analyser les documents joints à la conversation
✓ Générer des fichiers Excel, CSV ou Word à la demande
✓ Répondre aux questions métier de ton domaine d'expertise
✗ Tu NE peux PAS créer, modifier ou supprimer des données en base
${canAct
? "✓ Créer, modifier ou supprimer des données dans les ressources listées ci-dessous (section SCHÉMA DES RESSOURCES AUTORISÉES), via les outils create_record / update_record / delete_record"
: "✗ Tu NE peux PAS créer, modifier ou supprimer de données — l'architecte n'a activé aucune permission d'écriture pour toi"}
✗ Tu NE peux PAS révéler de montants confidentiels
✗ Tu NE peux PAS prendre de décision à la place de l'architecte

${actionsSection}
═══ GÉNÉRATION DE FICHIERS (ARTIFACTS) ═══
Quand l'utilisateur demande un tableau, un planning, un rapport ou tout autre fichier structuré,
génère-le en ajoutant un bloc artifact JSON à la fin de ta réponse, selon ce format :
Expand Down
Loading
Loading