From ff6f4fa46f365b164c21436766da9cbbf27c496a Mon Sep 17 00:00:00 2001 From: Daniel Fu Date: Thu, 4 Dec 2025 15:13:07 +0800 Subject: [PATCH 1/5] chore(package): lock packageManager to bun@1.3.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8496c16f0d..22548f1f26 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "lint-staged": { "*.{js,jsx,ts,tsx,json,md}": "prettier --write" }, - "packageManager": "bun@1.1.36", + "packageManager": "bun@1.3.3", "private": true, "scripts": { "build": "turbo build", From 9ed98238e7ffb715973339e5be175686a7c7206d Mon Sep 17 00:00:00 2001 From: Daniel Fu Date: Thu, 4 Dec 2025 16:36:06 +0800 Subject: [PATCH 2/5] refactor(policy): update policy details and AI assistant components for improved functionality --- .../editor/components/PolicyDetails.tsx | 59 ++++++--- .../components/ai/policy-ai-assistant.tsx | 115 ++++++++++++++++-- .../[policyId]/editor/tools/policy-tools.ts | 45 +++++++ .../policies/[policyId]/editor/types/index.ts | 4 + .../app/api/policies/[policyId]/chat/route.ts | 82 ++++--------- .../src/components/editor/policy-editor.tsx | 14 +-- .../components/ai-elements/conversation.tsx | 43 +++++-- .../ui/src/components/ai-elements/message.tsx | 10 +- .../components/ai-elements/prompt-input.tsx | 8 +- .../ui/src/components/ai-elements/tool.tsx | 75 ++++-------- 10 files changed, 293 insertions(+), 162 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index ab303af210..d9c0534170 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -10,22 +10,16 @@ import '@comp/ui/editor.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import type { PolicyDisplayFormat } from '@db'; import type { JSONContent } from '@tiptap/react'; -import { - DefaultChatTransport, - getToolName, - isToolUIPart, - type ToolUIPart, - type UIMessage, -} from 'ai'; +import { DefaultChatTransport } from 'ai'; import { structuredPatch } from 'diff'; import { CheckCircle, Loader2, Sparkles, X } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; -import { useFeatureFlagEnabled } from 'posthog-js/react'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { switchPolicyDisplayFormatAction } from '../../actions/switch-policy-display-format'; import { PdfViewer } from '../../components/PdfViewer'; import { updatePolicy } from '../actions/update-policy'; +import type { PolicyChatUIMessage } from '../types'; import { markdownToTipTapJSON } from './ai/markdown-utils'; import { PolicyAiAssistant } from './ai/policy-ai-assistant'; @@ -49,24 +43,32 @@ interface LatestProposal { key: string; content: string; summary: string; + title: string; + detail: string; + reviewHint: string; } -function getLatestProposedPolicy(messages: UIMessage[]): LatestProposal | null { +function getLatestProposedPolicy(messages: PolicyChatUIMessage[]): LatestProposal | null { const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant'); if (!lastAssistantMessage?.parts) return null; let latest: LatestProposal | null = null; lastAssistantMessage.parts.forEach((part, index) => { - if (!isToolUIPart(part) || getToolName(part) !== 'proposePolicy') return; - const toolPart = part as ToolUIPart; - const input = toolPart.input as { content?: string; summary?: string } | undefined; + if (part.type !== 'tool-proposePolicy') return; + if (part.state === 'input-streaming' || part.state === 'output-error') return; + const input = part.input; if (!input?.content) return; latest = { key: `${lastAssistantMessage.id}:${index}`, content: input.content, summary: input.summary ?? 'Proposing policy changes', + title: input.title ?? input.summary ?? 'Policy updates ready for your review', + detail: + input.detail ?? + 'I have prepared an updated version of this policy based on your instructions.', + reviewHint: input.reviewHint ?? 'Review the proposed changes below before applying them.', }; }); @@ -100,13 +102,18 @@ export function PolicyContentManager({ const [dismissedProposalKey, setDismissedProposalKey] = useState(null); const [isApplying, setIsApplying] = useState(false); const [chatErrorMessage, setChatErrorMessage] = useState(null); - const isAiPolicyAssistantEnabled = useFeatureFlagEnabled('is-ai-policy-assistant-enabled'); + const diffViewerRef = useRef(null); + + const isAiPolicyAssistantEnabled = true; + const scrollToDiffViewer = useCallback(() => { + diffViewerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); const { messages, status, sendMessage: baseSendMessage, - } = useChat({ + } = useChat({ transport: new DefaultChatTransport({ api: `/api/policies/${policyId}/chat`, }), @@ -128,6 +135,20 @@ export function PolicyContentManager({ const proposedPolicyMarkdown = activeProposal?.content ?? null; + const hasPendingProposal = useMemo( + () => + messages.some( + (m) => + m.role === 'assistant' && + m.parts?.some( + (part) => + part.type === 'tool-proposePolicy' && + (part.state === 'input-streaming' || part.state === 'input-available'), + ), + ), + [messages], + ); + const switchFormat = useAction(switchPolicyDisplayFormatAction, { onError: () => toast.error('Failed to switch view.'), }); @@ -226,6 +247,8 @@ export function PolicyContentManager({ errorMessage={chatErrorMessage} sendMessage={sendMessage} close={() => setShowAiAssistant(false)} + onScrollToDiff={scrollToDiffViewer} + hasActiveProposal={!!activeProposal && !hasPendingProposal} /> )} @@ -233,8 +256,8 @@ export function PolicyContentManager({ - {proposedPolicyMarkdown && diffPatch && activeProposal && ( -
+ {proposedPolicyMarkdown && diffPatch && activeProposal && !hasPendingProposal && ( +
+ )}

); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts new file mode 100644 index 0000000000..1b0e1b01bc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools.ts @@ -0,0 +1,45 @@ +import { type InferUITools, tool } from 'ai'; +import { z } from 'zod'; + +export function getPolicyTools() { + return { + proposePolicy: tool({ + description: + 'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.', + inputSchema: z.object({ + content: z + .string() + .describe( + 'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.', + ), + summary: z + .string() + .describe('One to two sentences summarizing the changes. No bullet points.'), + title: z + .string() + .describe( + 'A short, sentence-case heading (~4–10 words) that clearly states the main change, for use in a small review banner.', + ), + detail: z + .string() + .describe( + 'One or two plain-text sentences briefly explaining what changed and why, shown in the review banner.', + ), + reviewHint: z + .string() + .describe( + 'A very short imperative phrase that tells the user to review the updated policy content in the editor below.', + ), + }), + execute: async ({ summary, title, detail, reviewHint }) => ({ + success: true, + summary, + title, + detail, + reviewHint, + }), + }), + }; +} + +export type PolicyToolSet = InferUITools>; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts index d08b5b2339..857b62bc7c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts @@ -1,4 +1,8 @@ +import type { UIMessage } from 'ai'; import { z } from 'zod'; +import type { PolicyToolSet } from '../tools/policy-tools'; + +export type PolicyChatUIMessage = UIMessage; export const policyDetailsSchema = z.object({ id: z.string(), diff --git a/apps/app/src/app/api/policies/[policyId]/chat/route.ts b/apps/app/src/app/api/policies/[policyId]/chat/route.ts index c7aacd8220..8e17917dc0 100644 --- a/apps/app/src/app/api/policies/[policyId]/chat/route.ts +++ b/apps/app/src/app/api/policies/[policyId]/chat/route.ts @@ -1,10 +1,10 @@ +import { getPolicyTools } from '@/app/(app)/[orgId]/policies/[policyId]/editor/tools/policy-tools'; import { auth } from '@/utils/auth'; import { openai } from '@ai-sdk/openai'; import { db } from '@db'; -import { convertToModelMessages, streamText, tool, type UIMessage } from 'ai'; +import { convertToModelMessages, streamText, type UIMessage } from 'ai'; import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; -import { z } from 'zod'; export const maxDuration = 60; @@ -80,43 +80,42 @@ Current Policy Content: ${policyContentText} --- -IMPORTANT: This assistant is ONLY for editing policies. You MUST always use one of the available tools. - Your role: 1. Edit and improve policies when asked 2. Ensure policies remain compliant with relevant frameworks 3. Maintain professional, clear language appropriate for official documentation -TOOL USAGE (MANDATORY): -- If the user asks you to make changes, edits, or improvements: use the proposePolicy tool -- If the user asks a question or anything that is NOT an edit request: use the returnQuestion tool - -COMMUNICATION STYLE: -- Be concise and direct. No lengthy explanations or preamble. -- Do not use bullet points in responses unless asked. -- One sentence to explain, then act. +WHEN TO USE THE proposePolicy TOOL: +- When the user explicitly asks you to make changes, edits, or improvements +- When you have a clear understanding of what changes to make +- Always provide the COMPLETE policy content, not just changes -WHEN MAKING POLICY CHANGES: -Use the proposePolicy tool immediately. State what you'll change in ONE sentence, then call the tool. +WHEN TO RESPOND WITHOUT THE TOOL: +- When you need to ask clarifying questions about what the user wants +- When the request is ambiguous and you need more information +- When acknowledging the user or providing brief explanations -WHEN USER ASKS A QUESTION: -Use the returnQuestion tool immediately. Do not answer the question directly. +COMMUNICATION STYLE: +- Be concise and direct +- Ask clarifying questions when the user's intent is unclear +- One sentence to explain, then act (use tool or ask question) -CRITICAL MARKDOWN FORMATTING RULES: +CRITICAL MARKDOWN FORMATTING RULES (when using proposePolicy): - Every heading MUST have text after the # symbols (e.g., "## Section Title", never just "##") - Preserve the original document structure and all sections - Use proper heading hierarchy (# for title, ## for sections, ### for subsections) -- Ensure all lists are properly formatted with consistent indentation - Do not leave any empty headings, paragraphs, or incomplete syntax - Do not truncate or abbreviate any section - include full content -- If you're unsure about a section, keep the original text -QUALITY CHECKLIST before submitting: -- All headings have proper titles after # symbols -- Document starts with a clear title (# Policy Title) -- All original sections are preserved unless explicitly asked to remove -- No markdown syntax errors (unclosed brackets, incomplete lists) -- Professional tone maintained throughout +BANNER METADATA (for the proposePolicy tool arguments): +- title: A short, sentence-case heading (~4–10 words) that clearly states the main change. Example: "Data retention protocols integrated". +- detail: One or two sentences (plain text, no bullet points) briefly explaining what you changed and why, in a calm and professional tone. +- reviewHint: A very short imperative phrase that tells the user to review the updated policy in the editor below (for example, "Review the updated Data retention section below."). + +When using the proposePolicy tool: +- Always provide the COMPLETE updated policy content in the content field. +- Always fill in title, detail, and reviewHint so the UI can show a small banner indicating that changes are ready to review. +- Keep title, detail, and reviewHint focused, specific, and free of markdown formatting. Keep responses helpful and focused on the policy editing task.`; @@ -125,37 +124,8 @@ Keep responses helpful and focused on the policy editing task.`; model: openai('gpt-5.1'), system: systemPrompt, messages: convertToModelMessages(messages), - toolChoice: 'required', - tools: { - proposePolicy: tool({ - description: - 'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.', - inputSchema: z.object({ - content: z - .string() - .describe( - 'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.', - ), - summary: z - .string() - .describe('One to two sentences summarizing the changes. No bullet points.'), - }), - execute: async ({ summary }) => ({ success: true, summary }), - }), - returnQuestion: tool({ - description: - 'Use this tool when the user asks a question instead of requesting an edit. This assistant is only for editing policies, not answering questions.', - inputSchema: z.object({ - question: z.string().describe('The question the user asked.'), - message: z - .string() - .describe( - 'A brief message explaining that this assistant is only for editing policies and suggesting they rephrase as an edit request.', - ), - }), - execute: async ({ question, message }) => ({ success: true, question, message }), - }), - }, + toolChoice: 'auto', + tools: getPolicyTools(), }); return result.toUIMessageStreamResponse(); diff --git a/apps/app/src/components/editor/policy-editor.tsx b/apps/app/src/components/editor/policy-editor.tsx index 95d631a99a..2540aa08c3 100644 --- a/apps/app/src/components/editor/policy-editor.tsx +++ b/apps/app/src/components/editor/policy-editor.tsx @@ -2,7 +2,6 @@ import { validateAndFixTipTapContent } from '@comp/ui/editor'; import type { JSONContent } from '@tiptap/react'; -import { useState } from 'react'; import AdvancedEditor from './advanced-editor'; interface PolicyEditorProps { @@ -12,17 +11,11 @@ interface PolicyEditorProps { } export function PolicyEditor({ content, readOnly = false, onSave }: PolicyEditorProps) { - const [editorContent, setEditorContent] = useState(null); - const documentContent = validateAndFixTipTapContent({ type: 'doc', content: Array.isArray(content) && content.length > 0 ? content : [], }); - const handleUpdate = (updatedContent: JSONContent) => { - setEditorContent(updatedContent); - }; - const handleSave = async (contentToSave: JSONContent): Promise => { if (!contentToSave || !onSave) return; @@ -38,12 +31,7 @@ export function PolicyEditor({ content, readOnly = false, onSave }: PolicyEditor return ( <> - + ); } diff --git a/packages/ui/src/components/ai-elements/conversation.tsx b/packages/ui/src/components/ai-elements/conversation.tsx index bc31c152ec..bc58b2852f 100644 --- a/packages/ui/src/components/ai-elements/conversation.tsx +++ b/packages/ui/src/components/ai-elements/conversation.tsx @@ -11,7 +11,10 @@ export type ConversationProps = ComponentProps; export const Conversation = ({ className, ...props }: ConversationProps) => ( ( export type ConversationContentProps = ComponentProps; export const ConversationContent = ({ className, ...props }: ConversationContentProps) => ( - + ); export type ConversationHeaderProps = ComponentProps<'div'>; export const ConversationHeader = ({ className, ...props }: ConversationHeaderProps) => (
); @@ -37,7 +50,13 @@ export const ConversationHeader = ({ className, ...props }: ConversationHeaderPr export type ConversationTitleProps = ComponentProps<'div'>; export const ConversationTitle = ({ className, ...props }: ConversationTitleProps) => ( -
+
); export type ConversationEmptyStateProps = ComponentProps<'div'> & { @@ -56,7 +75,7 @@ export const ConversationEmptyState = ({ }: ConversationEmptyStateProps) => (
{icon &&
{icon}
}
-

{title}

- {description &&

{description}

} +

+ {title} +

+ {description && ( +

{description}

+ )}
)} @@ -88,7 +111,11 @@ export const ConversationScrollButton = ({ return ( !isAtBottom && (