Forced merge: merge ai-dev into master

This commit is contained in:
Flatlogic Bot 2026-05-11 15:33:32 +00:00
commit 64370f5a7c
3 changed files with 496 additions and 1 deletions

View File

@ -283,7 +283,7 @@ function config() {
projectId,
projectUuid: process.env.PROJECT_UUID || null,
projectHeader: process.env.AI_PROJECT_HEADER || "project-uuid",
defaultModel: process.env.AI_DEFAULT_MODEL || "gpt-5-mini",
defaultModel: process.env.AI_DEFAULT_MODEL || "gpt-5.2-codex",
timeout,
verifyTls,
};

View File

@ -0,0 +1,493 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { mdiShieldCheckOutline } from '@mdi/js'
import { useRouter } from 'next/router'
import BaseIcon from './BaseIcon'
import { aiResponse } from '../stores/openAiSlice'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
type CopilotRole = 'assistant' | 'user'
type CopilotMessage = {
id: string
role: CopilotRole
content: string
time?: string
}
type PageContext = {
title: string
focus: string
}
const pageContexts: Array<{ match: string; title: string; focus: string }> = [
{
match: '/governance-workbench',
title: 'Governance workbench',
focus: 'AI use-case intake, review paths, approval readiness, vendor assessments, and policy coverage.',
},
{
match: '/dashboard',
title: 'System overview',
focus: 'Executive visibility across AI adoption, risk posture, approvals, audit activity, and ROI.',
},
{
match: '/ai_use_cases',
title: 'AI request register',
focus: 'Registration of proposed AI workflows before they touch client work.',
},
{
match: '/approval_steps',
title: 'Approval queue',
focus: 'Partner, general counsel, IT/security, and ethics review decisions.',
},
{
match: '/usage_audit_logs',
title: 'Usage and audit log',
focus: 'Traceability for prompts, outputs, reviewers, decisions, and evidence.',
},
{
match: '/review_exceptions',
title: 'Review exceptions',
focus: 'Human-review gaps, risky shortcuts, and items needing remediation.',
},
{
match: '/ai_tools',
title: 'AI tool registry',
focus: 'Approved, suspended, and in-review AI tools with model and usage constraints.',
},
{
match: '/vendors',
title: 'Vendor registry',
focus: 'AI vendors, commercial owners, contracts, due diligence, and status.',
},
{
match: '/vendor_risk_assessments',
title: 'Vendor risk assessment',
focus: 'Security, data retention, client data use, subprocessors, deletion, and legal-specific risk.',
},
{
match: '/integrations',
title: 'Integrations',
focus: 'Connections to DMS, practice management, identity, workflow, reporting, and AI model layers.',
},
{
match: '/data_classifications',
title: 'Data sensitivity',
focus: 'Public, internal, confidential, privileged, and regulated matter data classifications.',
},
{
match: '/policies',
title: 'Policies and guardrails',
focus: 'AI usage policy, client notices, review rules, consent language, and prohibited use cases.',
},
{
match: '/human_review_checklists',
title: 'Human review checklists',
focus: 'Reviewer checklists before AI-assisted work is filed, sent, billed, or relied upon.',
},
{
match: '/checklist_items',
title: 'Checklist items',
focus: 'Atomic controls for human review, privilege checks, source verification, and output validation.',
},
{
match: '/training_courses',
title: 'Training courses',
focus: 'AI governance training for attorneys, legal ops, IT/security, and reviewers.',
},
{
match: '/practice_groups',
title: 'Practice groups',
focus: 'Practice-specific AI adoption patterns, owners, risk profiles, and rollout readiness.',
},
{
match: '/matter_types',
title: 'Matter types',
focus: 'Matter-specific governance for litigation, transactions, employment, privacy, and other work.',
},
{
match: '/roles_catalog',
title: 'Roles catalog',
focus: 'Governance roles, approval authority, access boundaries, and accountability.',
},
]
const starterPrompts = [
'Classify a deposition summary workflow and suggest the approval path.',
'Draft vendor risk questions for a legal AI tool.',
'What should we track to prove ROI for this AI workflow?',
]
const welcomeMessage: CopilotMessage = {
id: 'welcome',
role: 'assistant',
content:
'I can help map AI use cases, vendor reviews, human-review controls, and ROI evidence for this Legal AI Governance Hub. Tell me the workflow, tool, or risk you want to reason through.',
}
const getNow = () => {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const findPageContext = (path: string): PageContext => {
const match = pageContexts.find((item) => path.startsWith(item.match))
if (match) {
return {
title: match.title,
focus: match.focus,
}
}
return {
title: 'Legal AI Governance Hub',
focus: 'Controlled AI adoption across legal use cases, tools, vendors, policies, approvals, and audit evidence.',
}
}
const extractTextFromResponse = (response: any): string => {
const payload = response?.data || response
if (typeof payload === 'string') {
return payload
}
if (typeof payload?.output_text === 'string') {
return payload.output_text
}
if (typeof payload?.text === 'string') {
return payload.text
}
if (typeof payload?.message === 'string') {
return payload.message
}
if (payload?.choices?.[0]?.message?.content) {
return payload.choices[0].message.content
}
if (Array.isArray(payload?.output)) {
const outputText = payload.output
.flatMap((item) => item?.content || [])
.map((part) => part?.text || part?.content || '')
.filter(Boolean)
.join('\n')
if (outputText.trim()) {
return outputText
}
}
return ''
}
const getErrorMessage = (error: any) => {
if (typeof error === 'string') {
return error
}
if (typeof error?.message === 'string') {
return error.message
}
if (typeof error?.error?.message === 'string') {
return error.error.message
}
if (typeof error?.data?.error?.message === 'string') {
return error.data.error.message
}
return 'The AI service did not return a usable response. Please try again.'
}
const buildSystemPrompt = (page: PageContext, route: string, userLabel: string) => {
return `You are the AI Governance Copilot inside Legal AI Governance Hub.
Product context:
Legal AI Governance Hub is a control plane for law firms and legal departments adopting AI. It is not an AI lawyer and it is not a contract generation chatbot. It helps teams register AI use cases, classify data sensitivity, evaluate AI tools and vendors, route approvals, enforce human review, keep audit trails, and prove ROI.
Current app page: ${page.title}
Current route: ${route}
Current page focus: ${page.focus}
Signed-in workspace user: ${userLabel}
Core modules available in the app:
- AI use-case intake and risk classification
- Matter and client data sensitivity: public, internal, confidential, privileged, regulated
- AI tool registry: ChatGPT, Claude, Gemini, Harvey, CoCounsel, Lexis, Spellbook, internal tools
- Vendor registry and vendor risk assessments
- Approval workflows: partner, general counsel, IT/security, ethics/risk
- Policy and guardrail library
- Human-review checklists
- Usage and audit logs
- Training tracker
- ROI dashboard and adoption evidence
- Integrations with DMS, practice management, identity, workflow, reporting, and model providers
Behavior rules:
- Give practical governance and workflow guidance, not legal advice.
- Do not claim that you changed application data. You can suggest what the user should add or review.
- When useful, answer with concrete fields, approval steps, risks, and next actions.
- Prefer concise, executive-ready language for legal operations and firm leadership.
- Mention confidentiality, privilege, human review, vendor risk, and ROI when relevant.`
}
export default function AiGovernanceCopilot() {
const dispatch = useAppDispatch()
const router = useRouter()
const currentUser = useAppSelector((state) => state.auth.currentUser)
const isAskingResponse = useAppSelector((state) => state.openAi.isAskingResponse)
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [input, setInput] = useState('')
const [messages, setMessages] = useState<CopilotMessage[]>([welcomeMessage])
const [localError, setLocalError] = useState('')
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const page = useMemo(() => findPageContext(router.pathname), [router.pathname])
const userLabel = currentUser?.email || currentUser?.firstName || 'authenticated user'
const panelSizeClass = isExpanded
? 'fixed bottom-4 right-4 h-[calc(100vh-2rem)] w-[920px] max-w-[calc(100vw-2rem)] max-sm:bottom-3 max-sm:right-3 max-sm:h-[calc(100vh-1.5rem)] max-sm:w-[calc(100vw-1.5rem)]'
: 'fixed bottom-5 right-5 h-[720px] max-h-[calc(100vh-4rem)] w-[560px] max-w-[calc(100vw-2rem)] max-sm:bottom-3 max-sm:right-3 max-sm:h-[calc(100vh-1.5rem)] max-sm:w-[calc(100vw-1.5rem)]'
useEffect(() => {
if (!isOpen) {
return
}
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [messages, isOpen])
const askCopilot = async (prompt: string) => {
const cleanPrompt = prompt.trim()
if (!cleanPrompt || isAskingResponse) {
return
}
setLocalError('')
setInput('')
const userMessage: CopilotMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: cleanPrompt,
time: getNow(),
}
const history = messages.filter((message) => message.id !== welcomeMessage.id)
setMessages((currentMessages) => [...currentMessages, userMessage])
try {
const response = await dispatch(
aiResponse({
input: [
{ role: 'system', content: buildSystemPrompt(page, router.asPath, userLabel) },
...history.map((message) => ({ role: message.role, content: message.content })),
{ role: 'user', content: cleanPrompt },
],
options: {
poll_interval: 3,
poll_timeout: 180,
},
}),
).unwrap()
const responseText = extractTextFromResponse(response).trim()
if (!responseText) {
throw new Error('The AI service returned an empty response.')
}
const assistantMessage: CopilotMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: responseText,
time: getNow(),
}
setMessages((currentMessages) => [...currentMessages, assistantMessage])
} catch (error) {
console.error('AI Governance Copilot failed', error)
const errorMessage = getErrorMessage(error)
setLocalError(errorMessage)
setMessages((currentMessages) => [
...currentMessages,
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
content: `I could not complete that request. ${errorMessage}`,
time: getNow(),
},
])
}
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
await askCopilot(input)
}
const handleSuggestion = (prompt: string) => {
setInput(prompt)
setIsOpen(true)
}
if (!isOpen) {
return (
<button
type="button"
onClick={() => setIsOpen(true)}
className="fixed bottom-5 right-5 z-50 flex items-center gap-3 rounded-md border border-[#D8B75E]/50 bg-[#0E1A2B] px-4 py-3 text-left text-sm font-semibold text-white shadow-2xl shadow-[#0E1A2B]/25 transition hover:-translate-y-0.5 hover:border-[#D8B75E] hover:bg-[#13233A] focus:outline-none focus:ring-2 focus:ring-[#D8B75E] focus:ring-offset-2 focus:ring-offset-[#F6F3EC] dark:border-[#D8B75E]/60 dark:bg-[#D8B75E] dark:text-[#08111F] dark:shadow-black/40 dark:hover:bg-[#F0D98A] dark:focus:ring-offset-[#08111F]"
aria-label="Open AI Governance Copilot"
>
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-white/10 text-[#D8B75E] dark:bg-[#08111F] dark:text-[#D8B75E]">
<BaseIcon path={mdiShieldCheckOutline} size={22} />
</span>
<span className="hidden leading-tight sm:block">
<span className="block text-[11px] uppercase tracking-[0.18em] text-[#D8B75E] dark:text-[#7A5B13]">AI copilot</span>
<span className="block">Ask governance</span>
</span>
</button>
)
}
return (
<section
className={`${panelSizeClass} z-50 flex flex-col overflow-hidden rounded-xl border border-[#D8CBB9] bg-[#FBFAF7] shadow-2xl shadow-[#0E1A2B]/25 transition-all duration-200 dark:border-[#273447] dark:bg-[#0B1424] dark:shadow-black/50`}
aria-label="AI Governance Copilot"
>
<header className="border-b border-[#E5DDCF] bg-[#0E1A2B] px-4 py-4 text-white dark:border-[#273447]">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-[#D8B75E] text-[#08111F]">
<BaseIcon path={mdiShieldCheckOutline} size={22} />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#D8B75E]">Legal AI control plane</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">AI Governance Copilot</h2>
<p className="mt-1 text-xs leading-5 text-slate-300">Context: {page.title}</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => setIsExpanded((currentValue) => !currentValue)}
className="flex h-9 min-w-[4.25rem] items-center justify-center rounded-md border border-white/15 px-3 text-sm font-semibold text-slate-200 transition hover:border-[#D8B75E] hover:text-white focus:outline-none focus:ring-2 focus:ring-[#D8B75E]"
aria-label={isExpanded ? 'Reduce AI Governance Copilot' : 'Expand AI Governance Copilot'}
>
{isExpanded ? 'Min' : 'Max'}
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-md border border-white/15 text-lg font-semibold text-slate-200 transition hover:border-[#D8B75E] hover:text-white focus:outline-none focus:ring-2 focus:ring-[#D8B75E]"
aria-label="Close AI Governance Copilot"
>
X
</button>
</div>
</div>
</header>
<div className="border-b border-[#E5DDCF] bg-[#F6F3EC] px-4 py-2.5 dark:border-[#273447] dark:bg-[#111C2E]">
<div className="rounded-lg border border-[#E2D7C4] bg-white px-3 py-2.5 dark:border-[#273447] dark:bg-[#0B1424]">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#7A5B13] dark:text-[#D8B75E]">Workspace context</p>
<p className="mt-1 text-sm leading-5 text-[#374151] dark:text-slate-300">{page.focus}</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
{messages.map((message) => (
<article
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[88%] rounded-xl px-4 py-3 text-sm leading-6 shadow-sm ${
message.role === 'user'
? 'bg-[#0E1A2B] text-white dark:bg-[#D8B75E] dark:text-[#08111F]'
: 'border border-[#E2D7C4] bg-white text-[#1F2937] dark:border-[#273447] dark:bg-[#111C2E] dark:text-slate-100'
}`}
>
<div className="whitespace-pre-line">{message.content}</div>
{message.time ? (
<div
className={`mt-2 text-[11px] ${
message.role === 'user' ? 'text-slate-300 dark:text-[#7A5B13]' : 'text-[#8A8172] dark:text-slate-500'
}`}
>
{message.time}
</div>
) : null}
</div>
</article>
))}
{isAskingResponse ? (
<div className="flex justify-start">
<div className="rounded-xl border border-[#E2D7C4] bg-white px-4 py-3 text-sm text-[#4B5563] shadow-sm dark:border-[#273447] dark:bg-[#111C2E] dark:text-slate-300">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#D8B75E]" />
Reviewing governance context...
</span>
</div>
</div>
) : null}
<div ref={messagesEndRef} />
</div>
</div>
<div className="border-t border-[#E5DDCF] bg-white px-4 py-4 dark:border-[#273447] dark:bg-[#0B1424]">
<div className="mb-3 flex gap-2 overflow-x-auto pb-1">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => handleSuggestion(prompt)}
className="shrink-0 rounded-md border border-[#D8CBB9] bg-[#F6F3EC] px-3 py-2 text-xs font-semibold text-[#374151] transition hover:border-[#D8B75E] hover:text-[#0E1A2B] focus:outline-none focus:ring-2 focus:ring-[#D8B75E] dark:border-[#273447] dark:bg-[#111C2E] dark:text-slate-300 dark:hover:border-[#D8B75E] dark:hover:text-white"
>
{prompt}
</button>
))}
</div>
{localError ? (
<p className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-200">
{localError}
</p>
) : null}
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void askCopilot(input)
}
}}
rows={3}
placeholder="Ask about an AI use case, vendor risk, review path, policy gap, or ROI evidence..."
className="w-full resize-none rounded-lg border border-[#D8CBB9] bg-[#FBFAF7] px-3 py-3 text-sm leading-6 text-[#0E1A2B] placeholder:text-[#8A8172] focus:border-[#D8B75E] focus:outline-none focus:ring-2 focus:ring-[#D8B75E]/40 dark:border-[#273447] dark:bg-[#08111F] dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs leading-5 text-[#6B7280] dark:text-slate-500">Governance support only. Not legal advice.</p>
<button
type="submit"
disabled={!input.trim() || isAskingResponse}
className="rounded-md bg-[#0E1A2B] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-[#13233A] focus:outline-none focus:ring-2 focus:ring-[#D8B75E] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#D8B75E] dark:text-[#08111F] dark:hover:bg-[#F0D98A]"
>
{isAskingResponse ? 'Thinking' : 'Send'}
</button>
</div>
</form>
</div>
</section>
)
}

View File

@ -8,6 +8,7 @@ import NavBar from '../components/NavBar'
import NavBarItemPlain from '../components/NavBarItemPlain'
import AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar'
import AiGovernanceCopilot from '../components/AiGovernanceCopilot'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
import { useRouter } from 'next/router'
@ -156,6 +157,7 @@ export default function LayoutAuthenticated({
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<AiGovernanceCopilot />
<FooterBar>Controlled AI adoption for legal teams.</FooterBar>
</div>
</div>