Skip to content

Commit e735c29

Browse files
github-actions[bot]Itsnotakaampcode-com
authored
[dev] [Itsnotaka] daniel/ai-policy-edit (#1838)
* feat(api): add AI chat endpoint for policy editing assistance, initial draft for ai policy edits * fix: type error * feat(policy-editor): integrate AI-assisted policy editing with markdown support * refactor(api): streamline POST function and enhance markdown guidelines * refactor(policy-editor): improve policy details layout and diff viewer integration * refactor(policy-editor): simplify policy details component and enhance AI assistant integration * refactor(policy-editor): remove unused AI assistant logic and simplify component structure * feat(ui): add new components to package.json for diff viewer and AI elements * chore: update lockfile * refactor(tsconfig): reorganize compiler options and update paths * fix(policies): resolve infinite loop in policy AI assistant * fix(api): update policy editing assistant instructions and tool usage --------- Co-authored-by: Daniel Fu <[email protected]> Co-authored-by: Amp <[email protected]>
1 parent a8aaa9a commit e735c29

File tree

5 files changed

+338
-154
lines changed

5 files changed

+338
-154
lines changed

.husky/commit-msg

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
#!/usr/bin/env sh
2-
. "$(dirname "$0")/_/husky.sh"
3-
41
npx commitlint --edit $1

apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
'use client';
22

33
import { PolicyEditor } from '@/components/editor/policy-editor';
4+
import { useChat } from '@ai-sdk/react';
45
import { Button } from '@comp/ui/button';
56
import { Card, CardContent } from '@comp/ui/card';
6-
77
import { DiffViewer } from '@comp/ui/diff-viewer';
88
import { validateAndFixTipTapContent } from '@comp/ui/editor';
99
import '@comp/ui/editor.css';
1010
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs';
1111
import type { PolicyDisplayFormat } from '@db';
1212
import type { JSONContent } from '@tiptap/react';
13+
import {
14+
DefaultChatTransport,
15+
getToolName,
16+
isToolUIPart,
17+
type ToolUIPart,
18+
type UIMessage,
19+
} from 'ai';
1320
import { structuredPatch } from 'diff';
1421
import { CheckCircle, Loader2, Sparkles, X } from 'lucide-react';
1522
import { useAction } from 'next-safe-action/hooks';
@@ -22,6 +29,50 @@ import { updatePolicy } from '../actions/update-policy';
2229
import { markdownToTipTapJSON } from './ai/markdown-utils';
2330
import { PolicyAiAssistant } from './ai/policy-ai-assistant';
2431

32+
function mapChatErrorToMessage(error: unknown): string {
33+
const e = error as { status?: number };
34+
const status = e?.status;
35+
36+
if (status === 401 || status === 403) {
37+
return "You don't have access to this policy's AI assistant.";
38+
}
39+
if (status === 404) {
40+
return 'This policy could not be found. It may have been removed.';
41+
}
42+
if (status === 429) {
43+
return 'Too many requests. Please wait a moment and try again.';
44+
}
45+
return 'The AI assistant is currently unavailable. Please try again.';
46+
}
47+
48+
interface LatestProposal {
49+
key: string;
50+
content: string;
51+
summary: string;
52+
}
53+
54+
function getLatestProposedPolicy(messages: UIMessage[]): LatestProposal | null {
55+
const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant');
56+
if (!lastAssistantMessage?.parts) return null;
57+
58+
let latest: LatestProposal | null = null;
59+
60+
lastAssistantMessage.parts.forEach((part, index) => {
61+
if (!isToolUIPart(part) || getToolName(part) !== 'proposePolicy') return;
62+
const toolPart = part as ToolUIPart;
63+
const input = toolPart.input as { content?: string; summary?: string } | undefined;
64+
if (!input?.content) return;
65+
66+
latest = {
67+
key: `${lastAssistantMessage.id}:${index}`,
68+
content: input.content,
69+
summary: input.summary ?? 'Proposing policy changes',
70+
};
71+
});
72+
73+
return latest;
74+
}
75+
2576
interface PolicyContentManagerProps {
2677
policyId: string;
2778
policyContent: JSONContent | JSONContent[];
@@ -46,10 +97,37 @@ export function PolicyContentManager({
4697
return formattedContent;
4798
});
4899

49-
const [proposedPolicyMarkdown, setProposedPolicyMarkdown] = useState<string | null>(null);
100+
const [dismissedProposalKey, setDismissedProposalKey] = useState<string | null>(null);
50101
const [isApplying, setIsApplying] = useState(false);
102+
const [chatErrorMessage, setChatErrorMessage] = useState<string | null>(null);
51103
const isAiPolicyAssistantEnabled = useFeatureFlagEnabled('is-ai-policy-assistant-enabled');
52104

105+
const {
106+
messages,
107+
status,
108+
sendMessage: baseSendMessage,
109+
} = useChat({
110+
transport: new DefaultChatTransport({
111+
api: `/api/policies/${policyId}/chat`,
112+
}),
113+
onError(error) {
114+
console.error('Policy AI chat error:', error);
115+
setChatErrorMessage(mapChatErrorToMessage(error));
116+
},
117+
});
118+
119+
const sendMessage = (payload: { text: string }) => {
120+
setChatErrorMessage(null);
121+
baseSendMessage(payload);
122+
};
123+
124+
const latestProposal = useMemo(() => getLatestProposedPolicy(messages), [messages]);
125+
126+
const activeProposal =
127+
latestProposal && latestProposal.key !== dismissedProposalKey ? latestProposal : null;
128+
129+
const proposedPolicyMarkdown = activeProposal?.content ?? null;
130+
53131
const switchFormat = useAction(switchPolicyDisplayFormatAction, {
54132
onError: () => toast.error('Failed to switch view.'),
55133
});
@@ -65,15 +143,17 @@ export function PolicyContentManager({
65143
}, [currentPolicyMarkdown, proposedPolicyMarkdown]);
66144

67145
async function applyProposedChanges() {
68-
if (!proposedPolicyMarkdown) return;
146+
if (!activeProposal) return;
147+
148+
const { content, key } = activeProposal;
69149

70150
setIsApplying(true);
71151
try {
72-
const jsonContent = markdownToTipTapJSON(proposedPolicyMarkdown);
152+
const jsonContent = markdownToTipTapJSON(content);
73153
await updatePolicy({ policyId, content: jsonContent });
74154
setCurrentContent(jsonContent);
75155
setEditorKey((prev) => prev + 1);
76-
setProposedPolicyMarkdown(null);
156+
setDismissedProposalKey(key);
77157
toast.success('Policy updated with AI suggestions');
78158
} catch (err) {
79159
console.error('Failed to apply changes:', err);
@@ -141,8 +221,10 @@ export function PolicyContentManager({
141221
{showAiAssistant && isAiPolicyAssistantEnabled && (
142222
<div className="w-80 shrink-0 min-h-[400px] self-stretch flex flex-col">
143223
<PolicyAiAssistant
144-
policyId={policyId}
145-
onProposedPolicyChange={setProposedPolicyMarkdown}
224+
messages={messages}
225+
status={status}
226+
errorMessage={chatErrorMessage}
227+
sendMessage={sendMessage}
146228
close={() => setShowAiAssistant(false)}
147229
/>
148230
</div>
@@ -151,10 +233,14 @@ export function PolicyContentManager({
151233
</CardContent>
152234
</Card>
153235

154-
{proposedPolicyMarkdown && diffPatch && (
236+
{proposedPolicyMarkdown && diffPatch && activeProposal && (
155237
<div className="space-y-2">
156238
<div className="flex items-center justify-end gap-2">
157-
<Button variant="ghost" size="sm" onClick={() => setProposedPolicyMarkdown(null)}>
239+
<Button
240+
variant="ghost"
241+
size="sm"
242+
onClick={() => setDismissedProposalKey(activeProposal.key)}
243+
>
158244
<X className="h-3 w-3 mr-1" />
159245
Dismiss
160246
</Button>

apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx

Lines changed: 30 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,58 +9,43 @@ import {
99
import { Tool, ToolHeader } from '@comp/ui/ai-elements/tool';
1010
import { Button } from '@comp/ui/button';
1111
import { cn } from '@comp/ui/cn';
12-
import { DefaultChatTransport, getToolName, isToolUIPart } from 'ai';
13-
import type { ToolUIPart } from 'ai';
14-
import { useChat } from '@ai-sdk/react';
12+
import {
13+
getToolName,
14+
isToolUIPart,
15+
type ChatStatus,
16+
type ToolUIPart,
17+
type UIMessage,
18+
} from 'ai';
1519
import { X } from 'lucide-react';
16-
import { useEffect, useRef, useState } from 'react';
20+
import { useState } from 'react';
1721

1822
interface PolicyAiAssistantProps {
19-
policyId: string;
20-
onProposedPolicyChange?: (content: string | null) => void;
23+
messages: UIMessage[];
24+
status: ChatStatus;
25+
errorMessage?: string | null;
26+
sendMessage: (payload: { text: string }) => void;
2127
close?: () => void;
2228
}
2329

2430
export function PolicyAiAssistant({
25-
policyId,
26-
onProposedPolicyChange,
31+
messages,
32+
status,
33+
errorMessage,
34+
sendMessage,
2735
close,
2836
}: PolicyAiAssistantProps) {
2937
const [input, setInput] = useState('');
30-
const lastProcessedToolCallRef = useRef<string | null>(null);
31-
32-
const { messages, status, error, sendMessage } = useChat({
33-
transport: new DefaultChatTransport({
34-
api: `/api/policies/${policyId}/chat`,
35-
}),
36-
});
3738

3839
const isLoading = status === 'streaming' || status === 'submitted';
3940

40-
useEffect(() => {
41-
const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant');
42-
if (!lastAssistantMessage?.parts) return;
43-
44-
for (const part of lastAssistantMessage.parts) {
45-
if (isToolUIPart(part) && getToolName(part) === 'proposePolicy') {
46-
const toolInput = part.input as { content: string; summary: string };
47-
48-
if (part.state === 'input-streaming') {
49-
onProposedPolicyChange?.(toolInput?.content || '');
50-
continue;
51-
}
52-
53-
if (lastProcessedToolCallRef.current === part.toolCallId) {
54-
continue;
55-
}
56-
57-
if (toolInput?.content) {
58-
lastProcessedToolCallRef.current = part.toolCallId;
59-
onProposedPolicyChange?.(toolInput.content);
60-
}
61-
}
62-
}
63-
}, [messages, onProposedPolicyChange]);
41+
const hasActiveTool = messages.some(
42+
(m) =>
43+
m.role === 'assistant' &&
44+
m.parts.some(
45+
(p) =>
46+
isToolUIPart(p) && (p.state === 'input-streaming' || p.state === 'input-available'),
47+
),
48+
);
6449

6550
const handleSubmit = () => {
6651
if (!input.trim()) return;
@@ -108,10 +93,10 @@ export function PolicyAiAssistant({
10893
</div>
10994
);
11095
}
111-
96+
11297
if (isToolUIPart(part) && getToolName(part) === 'proposePolicy') {
11398
const toolPart = part as ToolUIPart;
114-
const toolInput = part.input as { content: string; summary: string };
99+
const toolInput = toolPart.input as { content?: string; summary?: string };
115100
return (
116101
<Tool key={`${message.id}-${index}`} className="mt-2">
117102
<ToolHeader
@@ -122,20 +107,20 @@ export function PolicyAiAssistant({
122107
</Tool>
123108
);
124109
}
125-
110+
126111
return null;
127112
})}
128113
</div>
129114
))
130115
)}
131-
{isLoading && (
116+
{isLoading && !hasActiveTool && (
132117
<div className="text-sm text-muted-foreground">Thinking...</div>
133118
)}
134119
</div>
135120

136-
{error && (
121+
{errorMessage && (
137122
<div className="border-t bg-destructive/10 px-3 py-2">
138-
<p className="text-xs text-destructive">{error.message}</p>
123+
<p className="text-xs text-destructive">{errorMessage}</p>
139124
</div>
140125
)}
141126

0 commit comments

Comments
 (0)