diff --git a/README.md b/README.md index 221f4ff..890c7b9 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,112 @@ bun dev Open [https://localhost:3000](https://localhost:3000) with your browser to see the result. OAuth sign-in (especially Safari) requires secure cookies, so dev now starts with HTTPS by default. Use [https://localhost:3000](https://localhost:3000). +## Excel Add-in (MVP) + +This repo includes an Excel task pane add-in surface backed by Office.js: + +- Task pane page: `/excel-addin` +- Planning endpoint: `/api/chat/excel/step` +- Manifest: `public/excel-addin/manifest.xml` + +Current local Excel tool support in the add-in: + +- `spreadsheet_changeBatch` +- `spreadsheet_queryRange` +- `spreadsheet_readDocument` +- `spreadsheet_createSheet` +- `spreadsheet_updateSheet` +- `spreadsheet_formatRange` + +Run the app: + +```bash +yarn dev +``` + +Sideload the manifest into Excel: + +1. Open Excel. +2. Go to `Insert` -> `Add-ins` -> `My Add-ins` -> `Upload My Add-in`. +3. Select `public/excel-addin/manifest.xml`. +4. Open the task pane from the `RowsnColumns AI` ribbon button. + +Notes: + +- The manifest is preconfigured for `https://localhost:3000`. +- If you host elsewhere, update URLs in `public/excel-addin/manifest.xml`. + +### Testing The Excel Add-in (Manual QA) + +Use this checklist to verify the MVP end-to-end. + +1. Start app + verify route: + +```bash +yarn dev +``` + +Open: + +- `https://localhost:3000/excel-addin` (should render the taskpane UI in browser) +- Sign in if prompted (the chat endpoint requires auth session cookies) + +2. Sideload in Excel: + +- Open Excel desktop or Excel on web. +- Upload `public/excel-addin/manifest.xml`. +- Open `RowsnColumns AI` from the ribbon. +- Confirm header shows `Workbook connected`. + +3. Verify context wiring: + +- Click a cell (for example `B2`) in Excel. +- In taskpane, verify active cell indicator updates to the same address. +- Click `Refresh context` if needed. + +4. Test supported tools with prompts: + +- Query range: + - Prompt: `Read A1:C5 and summarize what is there.` + - Expect: assistant invokes `spreadsheet_readDocument` or `spreadsheet_queryRange` and returns values. +- Write values: + - Prompt: `Put headers Name, Revenue, Cost in A1:C1 and add 3 sample rows.` + - Expect: assistant invokes `spreadsheet_changeBatch`; sheet updates. +- Create sheet: + - Prompt: `Create a new sheet called Summary.` + - Expect: `spreadsheet_createSheet`; new tab appears. +- Update sheet: + - Prompt: `Rename sheet 1 to RawData and freeze the top row.` + - Expect: `spreadsheet_updateSheet`; name/freeze updates. +- Format: + - Prompt: `Format A1:C1 as bold with a light background.` + - Expect: `spreadsheet_formatRange`; visible formatting change. + +5. Verify tool cards: + +- Each tool call should appear in chat with: + - Input JSON + - Output JSON + - Running/completed state + +6. Negative test (unsupported tool): + +- Prompt: `Delete rows 2 through 5.` +- Current MVP does not implement delete-row tool execution. +- Expect: tool result contains explicit `not implemented` error. + +### Troubleshooting + +- `Unauthorized` from chat: + - Sign in to the web app at `https://localhost:3000` first, then reopen the taskpane. +- Taskpane does not load: + - Confirm URLs in manifest match your dev host. + - Verify HTTPS is used (`yarn dev`, not `yarn dev:http`). +- Office.js errors after workbook changes: + - Click `Refresh context` and retry prompt. +- Manifest changes not reflected: + - Remove and re-sideload the add-in in Excel. + If you need plain HTTP for debugging, run: ```bash diff --git a/app/api/chat/excel/step/route.ts b/app/api/chat/excel/step/route.ts new file mode 100644 index 0000000..99d4ca7 --- /dev/null +++ b/app/api/chat/excel/step/route.ts @@ -0,0 +1,361 @@ +import { NextResponse } from "next/server"; +import { + AIMessage, + HumanMessage, + SystemMessage, + ToolMessage, + type BaseMessage, +} from "@langchain/core/messages"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatOpenAI } from "@langchain/openai"; + +import { isAdminUser } from "@/lib/auth/admin"; +import { auth } from "@/lib/auth/server"; +import { buildSpreadsheetContextInstructions } from "@/lib/chat/context"; +import type { + ExcelChatStepRequest, + ExcelChatStepResponse, + ExcelToolCall, +} from "@/lib/chat/excel-protocol"; +import { + mergeSystemInstructions, + normalizeInstructionText, +} from "@/lib/chat/instructions"; +import { spreadsheetTools } from "@/lib/chat/tools"; +import { listAssistantSkills } from "@/lib/skills/repository"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +const DEFAULT_OPENAI_MODEL = "gpt-5.4"; +const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"; +const CHAT_MODEL = process.env.CHAT_MODEL?.trim() || undefined; +const CHAT_SYSTEM_INSTRUCTIONS = + process.env.CHAT_SYSTEM_INSTRUCTIONS?.trim() || undefined; + +const EXCEL_TOOL_NAMES = new Set([ + "spreadsheet_changeBatch", + "spreadsheet_queryRange", + "spreadsheet_readDocument", + "spreadsheet_createSheet", + "spreadsheet_updateSheet", + "spreadsheet_formatRange", +]); + +const EXCEL_PLANNING_TOOLS = spreadsheetTools.filter((tool) => + EXCEL_TOOL_NAMES.has(tool.name), +); + +const normalizeProvider = ( + value: unknown, +): "openai" | "anthropic" | undefined => { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "openai" || normalized === "anthropic") { + return normalized; + } + return undefined; +}; + +const inferProviderFromModel = ( + model: string | undefined, +): "openai" | "anthropic" => { + if (!model) return "openai"; + return /^claude/i.test(model) ? "anthropic" : "openai"; +}; + +const safeJsonStringify = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +const parseToolArgs = (value: unknown) => { + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +}; + +const contentToText = (content: unknown): string => { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + const chunks: string[] = []; + for (const part of content) { + if (typeof part === "string") { + chunks.push(part); + continue; + } + if (part && typeof part === "object" && "text" in part) { + const text = (part as { text?: unknown }).text; + if (typeof text === "string") { + chunks.push(text); + } + } + } + return chunks.join(""); + } + + return ""; +}; + +const getLatestUserMessage = (messages: ExcelChatStepRequest["messages"]) => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === "user" && message.content.trim().length > 0) { + return message.content.trim(); + } + } + return ""; +}; + +const buildSystemPrompt = (input: { + userInstructions?: string; + contextInstructions?: string; + skillsInstructions?: string; +}) => { + const basePrompt = `You are an expert Excel assistant running inside an Office add-in. + +You MUST only use available tools when you need to read or modify workbook state. +Prefer safe, minimal edits and keep responses concise and practical. + +Important constraints: +- Use A1 notation in tool args. +- sheetId values are 1-based worksheet positions when provided. +- Keep formatting changes focused and avoid broad stylistic rewrites. +- If a request is unclear, make a reasonable assumption and proceed. + +Execution contract (mandatory): +- You are an execution agent, not a planner. +- If any tool result shows formula errors (#REF!, #VALUE!, #DIV/0!, #NAME?, #N/A, #NUM!, #NULL!, #SPILL!), immediately issue tool calls to repair them in this same run. +- After each repair, verify the affected range with spreadsheet_queryRange or spreadsheet_readDocument. +- Do not output "next step", "I will fix", "I can fix", or any future-tense repair promise. +- Only return a final assistant message when detected formula errors are resolved, or the tool-iteration limit is reached. +- If the tool-iteration limit is reached, report unresolved cells/ranges and the exact next repair action. +`; + + return mergeSystemInstructions( + mergeSystemInstructions( + mergeSystemInstructions( + normalizeInstructionText(basePrompt), + normalizeInstructionText(input.userInstructions), + ), + normalizeInstructionText(input.contextInstructions), + ), + normalizeInstructionText(input.skillsInstructions), + ); +}; + +const toConversationMessages = ( + input: ExcelChatStepRequest, + systemPrompt: string, +) => { + const messages: BaseMessage[] = [new SystemMessage(systemPrompt)]; + + for (const message of input.messages) { + const content = message.content.trim(); + if (!content) continue; + if (message.role === "user") { + messages.push(new HumanMessage(content)); + continue; + } + messages.push(new AIMessage(content)); + } + + for (const round of input.toolRounds ?? []) { + const normalizedCalls = round.toolCalls + .filter((call) => EXCEL_TOOL_NAMES.has(call.toolName)) + .map((call) => ({ + id: call.toolCallId, + name: call.toolName, + args: parseToolArgs(call.args), + type: "tool_call" as const, + })); + + if (normalizedCalls.length === 0) continue; + + messages.push( + new AIMessage({ + content: "", + tool_calls: normalizedCalls, + }), + ); + + for (const result of round.toolResults) { + messages.push( + new ToolMessage({ + content: safeJsonStringify(result.result), + tool_call_id: result.toolCallId, + name: result.toolName, + }), + ); + } + } + + return messages; +}; + +const toToolCalls = (rawToolCalls: unknown[]): ExcelToolCall[] => { + const result: ExcelToolCall[] = []; + + for (const entry of rawToolCalls) { + if (!entry || typeof entry !== "object") continue; + const toolCall = entry as { + id?: unknown; + name?: unknown; + args?: unknown; + }; + if (typeof toolCall.name !== "string") continue; + if (!EXCEL_TOOL_NAMES.has(toolCall.name)) continue; + + const toolCallId = + typeof toolCall.id === "string" && toolCall.id.trim().length > 0 + ? toolCall.id + : `tool_${Math.random().toString(36).slice(2, 10)}`; + + result.push({ + toolCallId, + toolName: toolCall.name, + args: parseToolArgs(toolCall.args), + }); + } + + return result; +}; + +export async function POST(request: Request) { + try { + const { data: session } = await auth.getSession(); + const user = session?.user; + if (!user?.id) { + return NextResponse.json( + { ok: false, error: "Unauthorized. Please sign in to continue." }, + { status: 401 }, + ); + } + + const body = (await request.json()) as ExcelChatStepRequest; + const threadId = body.threadId?.trim(); + if (!threadId) { + return NextResponse.json( + { + ok: false, + error: "threadId is required.", + } satisfies ExcelChatStepResponse, + { status: 400 }, + ); + } + + if (!Array.isArray(body.messages) || body.messages.length === 0) { + return NextResponse.json( + { + ok: false, + error: "messages are required.", + } satisfies ExcelChatStepResponse, + { status: 400 }, + ); + } + + const requestedModel = + typeof body.model === "string" && body.model.trim().length > 0 + ? body.model.trim() + : undefined; + const provider = + normalizeProvider(body.provider) ?? + inferProviderFromModel(requestedModel); + const model = + requestedModel || + CHAT_MODEL || + (provider === "anthropic" + ? DEFAULT_ANTHROPIC_MODEL + : DEFAULT_OPENAI_MODEL); + + let skillsInstructions = ""; + try { + const skills = await listAssistantSkills({ userId: user.id }); + if (skills.length > 0) { + const activeSkills = skills + .filter((skill) => skill.active) + .map((skill) => `Skill: ${skill.name}\n${skill.instructions}`) + .join("\n\n"); + skillsInstructions = activeSkills; + } + } catch (error) { + console.error("[chat/excel/step] Failed to load skills", error); + } + + const latestUserMessage = getLatestUserMessage(body.messages); + const contextInstructions = buildSpreadsheetContextInstructions( + body.context, + ); + const systemPrompt = + buildSystemPrompt({ + userInstructions: body.systemInstructions ?? CHAT_SYSTEM_INSTRUCTIONS, + contextInstructions, + skillsInstructions, + }) || "You are an expert Excel assistant."; + const conversation = toConversationMessages(body, systemPrompt); + + const llm = + provider === "anthropic" + ? new ChatAnthropic({ + model, + temperature: 0, + }) + : new ChatOpenAI({ + model, + temperature: 0, + }); + + const llmWithTools = llm.bindTools(EXCEL_PLANNING_TOOLS); + const response = await llmWithTools.invoke(conversation, { + metadata: { + threadId, + userId: user.id, + isAdmin: isAdminUser({ id: user.id, email: user.email }), + lastUserMessage: latestUserMessage.slice(0, 500), + }, + signal: request.signal, + }); + + const toolCalls = toToolCalls( + Array.isArray(response.tool_calls) ? response.tool_calls : [], + ); + if (toolCalls.length > 0) { + return NextResponse.json({ + ok: true, + type: "tool_calls", + toolCalls, + } satisfies ExcelChatStepResponse); + } + + const message = contentToText(response.content).trim(); + return NextResponse.json({ + ok: true, + type: "assistant", + message: message || "Done.", + } satisfies ExcelChatStepResponse); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to process Excel chat step."; + return NextResponse.json( + { + ok: false, + error: message, + } satisfies ExcelChatStepResponse, + { status: 500 }, + ); + } +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 989a23a..355039b 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { PopupComplete } from "@/app/auth/callback/popup-complete"; type SearchParams = Promise>; @@ -14,6 +15,12 @@ function normalizeRedirectPath(value: string | null): string { return "/doc"; } +function withSessionVerifier(redirectPath: string, verifier: string): string { + const redirectUrl = new URL(redirectPath, "http://localhost"); + redirectUrl.searchParams.set("neon_auth_session_verifier", verifier); + return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`; +} + function buildSignInErrorRedirect({ callbackPath, error, @@ -55,7 +62,14 @@ export default async function AuthCallbackPage({ searchParams: SearchParams; }) { const params = await searchParams; + const isPopupFlow = readSingleParam(params.neon_popup) === "1"; + if (isPopupFlow) { + return ; + } + const redirectTo = normalizeRedirectPath(readSingleParam(params.redirectTo)); + const verifier = + readSingleParam(params.neon_auth_session_verifier)?.trim() || null; const oauthError = readSingleParam(params.error); const oauthErrorDescription = readSingleParam(params.error_description) ?? @@ -71,5 +85,9 @@ export default async function AuthCallbackPage({ ); } + if (verifier) { + redirect(withSessionVerifier(redirectTo, verifier)); + } + redirect(redirectTo); } diff --git a/app/auth/callback/popup-complete.tsx b/app/auth/callback/popup-complete.tsx new file mode 100644 index 0000000..d2f2735 --- /dev/null +++ b/app/auth/callback/popup-complete.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect } from "react"; + +const OAUTH_POPUP_MESSAGE_TYPE = "neon-auth:oauth-complete"; +const NEON_AUTH_SESSION_VERIFIER_PARAM_NAME = "neon_auth_session_verifier"; +const NEON_AUTH_POPUP_CALLBACK_PARAM_NAME = "neon_popup_callback"; +const OAUTH_ERROR_PARAM_NAME = "error"; +const OAUTH_ERROR_DESCRIPTION_PARAM_NAME = "error_description"; + +function readParamFromSearchOrHash(params: URLSearchParams, key: string) { + const direct = params.get(key)?.trim(); + if (direct) return direct; + const hash = window.location.hash.startsWith("#") + ? window.location.hash.slice(1) + : window.location.hash; + if (!hash) return null; + const hashParams = new URLSearchParams(hash); + const fromHash = hashParams.get(key)?.trim(); + return fromHash || null; +} + +export function PopupComplete() { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const verifier = readParamFromSearchOrHash( + params, + NEON_AUTH_SESSION_VERIFIER_PARAM_NAME, + ); + const originalCallback = readParamFromSearchOrHash( + params, + NEON_AUTH_POPUP_CALLBACK_PARAM_NAME, + ); + const oauthError = readParamFromSearchOrHash(params, OAUTH_ERROR_PARAM_NAME); + const oauthErrorDescription = readParamFromSearchOrHash( + params, + OAUTH_ERROR_DESCRIPTION_PARAM_NAME, + ); + const payload = { + type: OAUTH_POPUP_MESSAGE_TYPE, + verifier, + originalCallback, + ...(oauthError ? { error: oauthError } : {}), + ...(oauthErrorDescription ? { errorDescription: oauthErrorDescription } : {}), + }; + + try { + if ( + typeof Office !== "undefined" && + Office.context?.ui && + typeof Office.context.ui.messageParent === "function" + ) { + Office.context.ui.messageParent(JSON.stringify(payload)); + window.close(); + return; + } + } catch { + // Ignore Office messaging failures and fall back to opener messaging. + } + + if (window.opener && window.opener !== window) { + window.opener.postMessage( + payload, + "*", + ); + window.close(); + return; + } + + // Fallback when popup opener is unavailable: return to callback target. + const fallbackUrl = + originalCallback && originalCallback.startsWith("/") + ? originalCallback + : "/excel-addin"; + if (verifier) { + const fallback = new URL(fallbackUrl, window.location.origin); + fallback.searchParams.set(NEON_AUTH_SESSION_VERIFIER_PARAM_NAME, verifier); + window.location.replace(`${fallback.pathname}${fallback.search}${fallback.hash}`); + return; + } + window.location.replace(fallbackUrl); + }, []); + + return ( +
+ Completing sign-in... +
+ ); +} diff --git a/app/auth/excel/start/page.tsx b/app/auth/excel/start/page.tsx new file mode 100644 index 0000000..c699e0e --- /dev/null +++ b/app/auth/excel/start/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import * as React from "react"; +import { authClient } from "@/lib/auth/client"; + +type SocialProvider = "google" | "github"; + +const isSocialProvider = (value: string): value is SocialProvider => + value === "google" || value === "github"; + +const normalizeCallbackPath = (value: string | null) => { + if (value && value.startsWith("/") && !value.startsWith("//")) { + return value; + } + return "/excel-addin"; +}; + +export default function ExcelAuthStartPage() { + const [error, setError] = React.useState(null); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + const providerValue = params.get("provider")?.trim() ?? ""; + const callbackValue = params.get("callbackURL"); + + if (!isSocialProvider(providerValue)) { + setError("Missing or invalid provider."); + return; + } + + const callbackURL = normalizeCallbackPath(callbackValue); + + void authClient.signIn + .social({ + provider: providerValue, + callbackURL, + errorCallbackURL: callbackURL, + newUserCallbackURL: callbackURL, + }) + .then((result) => { + if (result.error) { + setError(result.error.message ?? "Unable to start sign-in."); + } + }) + .catch((cause: unknown) => { + setError(cause instanceof Error ? cause.message : String(cause)); + }); + }, []); + + return ( +
+
+

Opening sign-in...

+ {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 3e2de52..1e531a2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -127,6 +127,19 @@ body { text-rendering: optimizeLegibility; } +/* Keep Office host styles from scaling rem-based chat spacing in Excel add-in. */ +body.excel-addin-body { + margin: 0; + font-size: 16px; + line-height: 1.5; + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} + +body.excel-addin-body #__next { + min-height: 100vh; +} + body.rnc-dark { background: radial-gradient(circle at top left, rgba(255, 141, 94, 0.18), transparent 36%), diff --git a/components/excel-addin/excel-assistant.tsx b/components/excel-addin/excel-assistant.tsx new file mode 100644 index 0000000..eb0c478 --- /dev/null +++ b/components/excel-addin/excel-assistant.tsx @@ -0,0 +1,1433 @@ +"use client"; + +import type { + ChatModelAdapter, + ThreadMessage, +} from "@assistant-ui/react"; +import type { + ReadonlyJSONObject, + ReadonlyJSONValue, +} from "assistant-stream/utils"; +import { + AssistantRuntimeProvider, + ThreadPrimitive, + useLocalRuntime, +} from "@assistant-ui/react"; +import * as React from "react"; +import { Github, Loader2, LogOut, RefreshCw, RotateCcw } from "lucide-react"; +import { + ModalProvider as RncModalProvider, + TooltipProvider as RncTooltipProvider, +} from "@rowsncolumns/ui"; + +import { + AssistantComposer, + AssistantMessage, + DEFAULT_ASSISTANT_MODEL, + ToolUIRegistry, + getAssistantModelLabel, +} from "@/components/excel-addin/excel-chat-ui"; +import { executeExcelToolCall } from "@/components/excel-addin/excel-tools"; +import { useExcelContext } from "@/components/excel-addin/excel-context"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { authClient } from "@/lib/auth/client"; +import type { SpreadsheetAssistantContext } from "@/lib/chat/context"; +import type { + ExcelChatHistoryMessage, + ExcelChatStepRequest, + ExcelChatStepResponse, + ExcelToolCall, + ExcelToolResult, + ExcelToolRound, +} from "@/lib/chat/excel-protocol"; + +const CHAT_STEP_ENDPOINT = "/api/chat/excel/step"; +const MAX_TOOL_STEPS = 8; +const OAUTH_POPUP_MESSAGE_TYPE = "neon-auth:oauth-complete"; +const ASSISTANT_TAGLINE = + "Plan edits, formulas, and workbook changes without leaving your sheet."; +const EXCEL_STARTER_PROMPTS = [ + "Build a monthly cash runway model with base, upside, and downside scenarios through the next 24 months.", + "Create a budget vs actual variance model with volume/price/mix bridges and executive commentary fields.", + "Audit this financial model for hardcoded values, broken links, circular references, and formula consistency.", +] as const; + +type SocialProvider = "google" | "github"; +type AuthStatus = "loading" | "authenticated" | "unauthenticated"; +type SessionUser = { + email?: string | null; +}; + +function GoogleBadge() { + return ( + + ); +} + +type StreamingTextPart = { + type: "text"; + text: string; +}; + +type StreamingToolPart = { + type: "tool-call"; + toolCallId: string; + toolName: string; + args: ReadonlyJSONObject; + argsText: string; + result?: unknown; +}; + +type StreamingContentPart = StreamingTextPart | StreamingToolPart; + +const toErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const sleep = (ms: number) => + new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); + +const createThreadId = () => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `thread_${Math.random().toString(36).slice(2, 10)}`; +}; + +const inferProviderForModel = ( + model: string, +): "openai" | "anthropic" => { + return /^claude/i.test(model) ? "anthropic" : "openai"; +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const stringifyUnknown = (value: unknown) => { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const toJsonValue = (value: unknown): ReadonlyJSONValue => { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => toJsonValue(item)); + } + if (isRecord(value)) { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = toJsonValue(entry); + } + return result; + } + return String(value); +}; + +const normalizeToolArgs = (value: unknown): ReadonlyJSONObject => { + if (isRecord(value)) { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = toJsonValue(entry); + } + return result; + } + if (Array.isArray(value)) { + return { items: toJsonValue(value) }; + } + if (value === undefined) { + return {}; + } + return { value: toJsonValue(value) }; +}; + +const snapshotParts = (parts: StreamingContentPart[]) => + parts.map((part) => (part.type === "tool-call" ? { ...part } : { ...part })); + +const buildYieldPayload = ( + parts: StreamingContentPart[], + threadId: string, + complete = false, +) => ({ + content: snapshotParts(parts), + ...(complete + ? { + status: { + type: "complete" as const, + reason: "stop" as const, + }, + } + : {}), + metadata: { + custom: { + threadId, + }, + }, +}); + +const upsertToolCall = ( + parts: StreamingContentPart[], + toolCallId: string, + toolName: string, + args: unknown, +) => { + const normalizedArgs = normalizeToolArgs(args); + const index = parts.findIndex( + (part) => part.type === "tool-call" && part.toolCallId === toolCallId, + ); + if (index === -1) { + parts.push({ + type: "tool-call", + toolCallId, + toolName, + args: normalizedArgs, + argsText: stringifyUnknown(normalizedArgs), + }); + return; + } + parts[index] = { + type: "tool-call", + toolCallId, + toolName, + args: normalizedArgs, + argsText: stringifyUnknown(normalizedArgs), + result: + parts[index].type === "tool-call" ? parts[index].result : undefined, + }; +}; + +const setToolResult = ( + parts: StreamingContentPart[], + toolCallId: string, + toolName: string, + result: unknown, +) => { + const index = parts.findIndex( + (part) => part.type === "tool-call" && part.toolCallId === toolCallId, + ); + if (index === -1) { + parts.push({ + type: "tool-call", + toolCallId, + toolName, + args: {}, + argsText: "{}", + result, + }); + return; + } + + const part = parts[index]; + if (part.type !== "tool-call") return; + parts[index] = { + ...part, + result, + }; +}; + +const appendText = (parts: StreamingContentPart[], text: string) => { + if (!text.trim()) return; + const lastPart = parts[parts.length - 1]; + if (lastPart && lastPart.type === "text") { + lastPart.text += text; + return; + } + parts.push({ type: "text", text }); +}; + +const extractSessionUser = (value: unknown): SessionUser | null => { + if (!isRecord(value)) return null; + + if (isRecord(value.user)) { + return value.user as SessionUser; + } + + if (isRecord(value.data) && isRecord(value.data.user)) { + return value.data.user as SessionUser; + } + + if ( + isRecord(value.data) && + isRecord(value.data.session) && + isRecord(value.data.user) + ) { + return value.data.user as SessionUser; + } + + return null; +}; + +const fetchSessionFromAuthEndpoint = async () => { + const response = await fetch("/api/auth/get-session", { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + "x-rnc-auth-source": "excel-addin", + }, + }); + + if (!response.ok) { + throw new Error(`Session endpoint failed (${response.status})`); + } + + const payload = (await response.json().catch(() => null)) as unknown; + return extractSessionUser(payload); +}; + +const startSamePaneSocialSignIn = async (provider: SocialProvider) => { + const callbackURL = `/auth/callback?redirectTo=${encodeURIComponent("/excel-addin")}`; + const response = await fetch("/api/auth/sign-in/social", { + method: "POST", + credentials: "include", + cache: "no-store", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + provider, + callbackURL, + }), + }); + + if (response.redirected) { + window.location.assign(response.url); + return; + } + + const payload = (await response.json().catch(() => null)) as unknown; + if (isRecord(payload) && typeof payload.url === "string") { + window.location.assign(payload.url); + return; + } + + if (!response.ok) { + throw new Error( + `Unable to start same-pane sign-in (${response.status} ${response.statusText})`, + ); + } + throw new Error("Unable to start same-pane sign-in."); +}; + +const openOfficeOAuthDialog = async (oauthUrl: string) => { + if ( + typeof Office === "undefined" || + !Office.context?.ui || + typeof Office.context.ui.displayDialogAsync !== "function" + ) { + throw new Error("Office dialog API unavailable."); + } + + return new Promise<{ verifier: string | null }>((resolve, reject) => { + Office.context.ui.displayDialogAsync( + oauthUrl, + { + height: 75, + width: 45, + promptBeforeOpen: false, + }, + (result) => { + if (result.status !== Office.AsyncResultStatus.Succeeded) { + reject( + new Error( + result.error?.message ?? "Unable to open Office auth dialog.", + ), + ); + return; + } + + const dialog = result.value; + let finished = false; + const finish = (input: { + ok: boolean; + verifier?: string | null; + error?: Error; + }) => { + if (finished) return; + finished = true; + try { + dialog.close(); + } catch { + // Ignore dialog close errors. + } + + if (input.ok) { + resolve({ + verifier: + typeof input.verifier === "string" && input.verifier.length > 0 + ? input.verifier + : null, + }); + return; + } + reject(input.error ?? new Error("Auth dialog was closed.")); + }; + + dialog.addEventHandler( + Office.EventType.DialogMessageReceived, + (event: { message: string; origin?: string } | { error: number }) => { + if (!("message" in event)) { + finish({ + ok: false, + error: new Error( + `Auth dialog message channel error (${event.error}).`, + ), + }); + return; + } + + try { + const payload = JSON.parse(event.message) as unknown; + if (isRecord(payload) && payload.type === OAUTH_POPUP_MESSAGE_TYPE) { + const maybeError = + typeof payload.error === "string" ? payload.error.trim() : ""; + if (maybeError.length > 0) { + finish({ + ok: false, + error: new Error(maybeError), + }); + return; + } + finish({ + ok: true, + verifier: + typeof payload.verifier === "string" + ? payload.verifier + : null, + }); + return; + } + if (isRecord(payload) && typeof payload.error === "string") { + finish({ + ok: false, + error: new Error(payload.error), + }); + return; + } + } catch { + // Ignore parse failures and handle raw message fallback below. + } + + finish({ + ok: true, + verifier: event.message, + }); + }, + ); + + dialog.addEventHandler( + Office.EventType.DialogEventReceived, + (event: { message: string; origin?: string } | { error: number }) => { + const code = "error" in event ? event.error : "unknown"; + finish({ + ok: false, + error: new Error( + `Auth dialog closed before completion (${code}).`, + ), + }); + }, + ); + }, + ); + }); +}; + +const readSessionVerifierFromUrl = (url: URL) => { + const direct = url.searchParams.get("neon_auth_session_verifier")?.trim(); + if (direct) return direct; + const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + if (!hash) return null; + const hashParams = new URLSearchParams(hash); + const fromHash = hashParams.get("neon_auth_session_verifier")?.trim(); + return fromHash || null; +}; + +const clearSessionVerifierFromUrl = () => { + const url = new URL(window.location.href); + const hadSearchParam = url.searchParams.has("neon_auth_session_verifier"); + const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const hashParams = new URLSearchParams(hash); + const hadHashParam = hashParams.has("neon_auth_session_verifier"); + if (!hadSearchParam && !hadHashParam) { + return false; + } + + if (hadSearchParam) { + url.searchParams.delete("neon_auth_session_verifier"); + } + if (hadHashParam) { + hashParams.delete("neon_auth_session_verifier"); + } + const nextHash = hashParams.toString(); + const nextUrl = `${url.pathname}${url.search}${nextHash ? `#${nextHash}` : ""}`; + try { + if (typeof window.history.replaceState === "function") { + // Avoid reading `history.state` in Office webview where proxied history + // can throw "Illegal invocation". + window.history.replaceState(null, "", nextUrl); + return true; + } + } catch { + // Ignore and fall through. + } + try { + window.location.replace(nextUrl); + return true; + } catch { + // Ignore and fall through. + } + return false; +}; + +const tryExchangeSessionVerifier = async () => { + const url = new URL(window.location.href); + const verifier = readSessionVerifierFromUrl(url); + if (!verifier) return false; + + const response = await fetch( + `/api/auth/get-session?neon_auth_session_verifier=${encodeURIComponent(verifier)}`, + { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + "x-rnc-auth-source": "excel-addin-verifier-exchange", + }, + }, + ); + + return response.ok; +}; + +const extractMessageText = (message: ThreadMessage | undefined) => { + if (!message) return ""; + return message.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("") + .trim(); +}; + +const toHistoryMessages = (messages: ThreadMessage[]): ExcelChatHistoryMessage[] => { + const result: ExcelChatHistoryMessage[] = []; + + for (const message of messages) { + if (message.role !== "user" && message.role !== "assistant") { + continue; + } + + const content = message.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("") + .trim(); + if (!content) continue; + + result.push({ + role: message.role, + content, + }); + } + + return result; +}; + +const requestExcelChatStep = async ( + input: ExcelChatStepRequest, + signal: AbortSignal, +) => { + const response = await fetch(CHAT_STEP_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + signal, + cache: "no-store", + }); + + const payload = (await response.json().catch(() => null)) as + | ExcelChatStepResponse + | null; + if (!response.ok || !payload) { + return { + ok: false, + error: "Failed to run assistant step.", + } satisfies ExcelChatStepResponse; + } + + return payload; +}; + +const getAssistantContextSnapshot = (input: { + workbookReady: boolean; + activeSheetId: number | null; + activeCell: { + rowIndex: number; + columnIndex: number; + a1Address: string; + } | null; + sheets: Array<{ sheetId: number; name: string }>; +}): SpreadsheetAssistantContext | undefined => { + if (!input.workbookReady) return undefined; + + return { + documentId: "excel-active-workbook", + sheets: input.sheets.map((sheet) => ({ + sheetId: sheet.sheetId, + title: sheet.name, + })), + ...(typeof input.activeSheetId === "number" + ? { activeSheetId: input.activeSheetId } + : {}), + ...(input.activeCell + ? { + activeCell: { + rowIndex: input.activeCell.rowIndex, + columnIndex: input.activeCell.columnIndex, + a1Address: input.activeCell.a1Address, + }, + } + : {}), + }; +}; + +export function ExcelAssistant() { + const { + isReady, + activeSheetId, + activeCell, + sheets, + runExcel, + refreshSnapshot, + } = useExcelContext(); + + const [selectedModel, setSelectedModel] = React.useState( + DEFAULT_ASSISTANT_MODEL, + ); + const [isModelPickerOpen, setIsModelPickerOpen] = React.useState(false); + const [reasoningEnabled, setReasoningEnabled] = React.useState(true); + const [authStatus, setAuthStatus] = React.useState("loading"); + const [authPendingProvider, setAuthPendingProvider] = + React.useState(null); + const [isSigningOut, setIsSigningOut] = React.useState(false); + const [authError, setAuthError] = React.useState(null); + const [signedInEmail, setSignedInEmail] = React.useState(null); + const [threadId, setThreadId] = React.useState(() => createThreadId()); + + const selectedModelRef = React.useRef(selectedModel); + const reasoningEnabledRef = React.useRef(reasoningEnabled); + const authStatusRef = React.useRef(authStatus); + const threadIdRef = React.useRef(threadId); + const contextRef = React.useRef({ + isReady, + activeSheetId, + activeCell, + sheets, + runExcel, + refreshSnapshot, + }); + + React.useEffect(() => { + selectedModelRef.current = selectedModel; + }, [selectedModel]); + + React.useEffect(() => { + document.body.classList.add("excel-addin-body"); + return () => { + document.body.classList.remove("excel-addin-body"); + }; + }, []); + + React.useEffect(() => { + reasoningEnabledRef.current = reasoningEnabled; + }, [reasoningEnabled]); + + React.useEffect(() => { + authStatusRef.current = authStatus; + }, [authStatus]); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("excel_dialog") !== "1") { + return; + } + + try { + if ( + typeof Office !== "undefined" && + Office.context?.ui && + typeof Office.context.ui.messageParent === "function" + ) { + Office.context.ui.messageParent( + JSON.stringify({ + type: OAUTH_POPUP_MESSAGE_TYPE, + verifier: null, + originalCallback: "/excel-addin", + }), + ); + window.close(); + } + } catch { + // Ignore dialog bridge failures. + } + }, []); + + const refreshAuthSession = React.useCallback(async () => { + setAuthError(null); + let hadVerifierInLocation = false; + try { + const locationUrl = new URL(window.location.href); + hadVerifierInLocation = Boolean(readSessionVerifierFromUrl(locationUrl)); + if (hadVerifierInLocation) { + const exchanged = await tryExchangeSessionVerifier(); + if (exchanged) { + clearSessionVerifierFromUrl(); + } + } + } catch { + // Ignore preflight verifier exchange failures. + } + + try { + const sessionResult = await authClient.getSession(); + const sdkUser = sessionResult.data?.user ?? null; + const user = sdkUser ?? (await fetchSessionFromAuthEndpoint()); + if (user) { + if (hadVerifierInLocation) { + clearSessionVerifierFromUrl(); + } + setAuthStatus("authenticated"); + const email = + typeof user.email === "string" && user.email.trim().length > 0 + ? user.email.trim() + : null; + setSignedInEmail(email); + return true; + } + setSignedInEmail(null); + setAuthStatus("unauthenticated"); + return false; + } catch (error) { + try { + await tryExchangeSessionVerifier(); + } catch { + // Ignore verifier exchange failures. + } + + const hadVerifier = clearSessionVerifierFromUrl(); + if (hadVerifier) { + try { + const retryResult = await authClient.getSession(); + const retryUser = + retryResult.data?.user ?? (await fetchSessionFromAuthEndpoint()); + if (retryUser) { + clearSessionVerifierFromUrl(); + setAuthStatus("authenticated"); + const email = + typeof retryUser.email === "string" && + retryUser.email.trim().length > 0 + ? retryUser.email.trim() + : null; + setSignedInEmail(email); + return true; + } + } catch { + // Ignore retry failures and fall through to standard error handling. + } + } + + try { + const fallbackUser = await fetchSessionFromAuthEndpoint(); + if (fallbackUser) { + setAuthStatus("authenticated"); + const email = + typeof fallbackUser.email === "string" && + fallbackUser.email.trim().length > 0 + ? fallbackUser.email.trim() + : null; + setSignedInEmail(email); + return true; + } + } catch { + // Ignore endpoint fallback errors; surface primary error below. + } + + setSignedInEmail(null); + setAuthStatus("unauthenticated"); + setAuthError( + `Unable to verify session. Please sign in. (${toErrorMessage(error)})`, + ); + return false; + } + }, []); + + React.useEffect(() => { + let active = true; + + const initializeAuth = async () => { + try { + await refreshAuthSession(); + } finally { + if (!active) return; + } + }; + + void initializeAuth(); + + const onFocus = () => { + void refreshAuthSession(); + }; + window.addEventListener("focus", onFocus); + + return () => { + active = false; + window.removeEventListener("focus", onFocus); + }; + }, [refreshAuthSession]); + + const handleSignIn = React.useCallback(async (provider: SocialProvider) => { + try { + setAuthError(null); + setAuthPendingProvider(provider); + + const dialogReturnPath = "/excel-addin?excel_dialog=1"; + const callbackPath = `/auth/callback?excel_dialog=1&redirectTo=${encodeURIComponent(dialogReturnPath)}&neon_popup=1&neon_popup_callback=${encodeURIComponent(dialogReturnPath)}`; + const officeDialogStartUrl = `${window.location.origin}/auth/excel/start?provider=${encodeURIComponent(provider)}&callbackURL=${encodeURIComponent(callbackPath)}`; + + if ( + typeof Office !== "undefined" && + Office.context?.ui && + typeof Office.context.ui.displayDialogAsync === "function" + ) { + try { + const dialogResult = await openOfficeOAuthDialog(officeDialogStartUrl); + if (dialogResult.verifier) { + await fetch( + `/api/auth/get-session?neon_auth_session_verifier=${encodeURIComponent(dialogResult.verifier)}`, + { + method: "GET", + credentials: "include", + cache: "no-store", + }, + ); + } + } catch (error) { + setAuthPendingProvider(null); + setAuthError(toErrorMessage(error)); + return; + } + + for (let attempt = 0; attempt < 15; attempt += 1) { + const isAuthenticated = await refreshAuthSession(); + if (isAuthenticated) { + setAuthPendingProvider(null); + return; + } + await sleep(700); + } + + setAuthPendingProvider(null); + setAuthError( + "Sign-in completed, but session is not available in Excel yet. Click refresh.", + ); + return; + } + + const popupCallbackURL = `/auth/callback?neon_popup=1&neon_popup_callback=${encodeURIComponent("/excel-addin")}`; + const signInResult = await authClient.signIn.social({ + provider, + callbackURL: popupCallbackURL, + disableRedirect: true, + }); + if (signInResult.error) { + throw new Error( + signInResult.error.message ?? "Unable to start social sign-in.", + ); + } + + const oauthUrl = + isRecord(signInResult.data) && typeof signInResult.data.url === "string" + ? signInResult.data.url + : null; + if (!oauthUrl) { + throw new Error("OAuth URL is missing from sign-in response."); + } + + const popup = window.open( + oauthUrl, + "rnc_excel_auth_popup", + "width=500,height=700,popup=yes", + ); + let completedViaDialog = false; + if (!popup || popup.closed) { + // Some Excel hosts block browser popups. Use Office dialog when possible. + try { + const dialogResult = await openOfficeOAuthDialog(oauthUrl); + if (dialogResult.verifier) { + await fetch( + `/api/auth/get-session?neon_auth_session_verifier=${encodeURIComponent(dialogResult.verifier)}`, + { + method: "GET", + credentials: "include", + cache: "no-store", + }, + ); + } + completedViaDialog = true; + } catch { + // Final fallback for hosts without Office dialog support. + await startSamePaneSocialSignIn(provider); + return; + } + } + + const popupCompleted = completedViaDialog + ? true + : await new Promise((resolve) => { + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + window.removeEventListener("message", onMessage); + if (pollTimer !== null) { + window.clearInterval(pollTimer); + } + if (timeoutTimer !== null) { + window.clearTimeout(timeoutTimer); + } + resolve(ok); + }; + + const onMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (!isRecord(event.data)) return; + if (event.data.type !== OAUTH_POPUP_MESSAGE_TYPE) return; + + const verifier = + typeof event.data.verifier === "string" + ? event.data.verifier.trim() + : ""; + if (verifier) { + void fetch( + `/api/auth/get-session?neon_auth_session_verifier=${encodeURIComponent(verifier)}`, + { + method: "GET", + credentials: "include", + cache: "no-store", + }, + ) + .catch(() => { + // Ignore verifier exchange errors here; session refresh handles it. + }) + .finally(() => finish(true)); + return; + } + if (typeof event.data.error === "string") { + finish(false); + return; + } + + finish(true); + }; + + window.addEventListener("message", onMessage); + + const pollTimer = window.setInterval(() => { + if (popup?.closed) { + finish(true); + } + }, 400); + + const timeoutTimer = window.setTimeout(() => { + try { + popup?.close(); + } catch { + // Ignore popup close errors. + } + finish(false); + }, 3 * 60 * 1000); + }); + + if (!popupCompleted) { + setAuthPendingProvider(null); + setAuthError("Sign-in timed out. Please try again."); + return; + } + + for (let attempt = 0; attempt < 15; attempt += 1) { + const isAuthenticated = await refreshAuthSession(); + if (isAuthenticated) { + setAuthPendingProvider(null); + return; + } + await sleep(700); + } + + try { + await startSamePaneSocialSignIn(provider); + return; + } catch { + // If same-pane fallback fails, surface the standard session error. + } + + setAuthPendingProvider(null); + setAuthError( + "Sign-in completed, but session is not available in Excel yet. Click refresh.", + ); + } catch (error) { + setAuthPendingProvider(null); + setAuthError(toErrorMessage(error)); + } + }, [refreshAuthSession]); + + React.useEffect(() => { + threadIdRef.current = threadId; + }, [threadId]); + + React.useEffect(() => { + contextRef.current = { + isReady, + activeSheetId, + activeCell, + sheets, + runExcel, + refreshSnapshot, + }; + }, [activeCell, activeSheetId, isReady, refreshSnapshot, runExcel, sheets]); + + const chatAdapter = React.useMemo( + () => ({ + async *run({ messages, abortSignal }) { + if (authStatusRef.current !== "authenticated") { + yield { + content: [ + { + type: "text", + text: "Please sign in before using the assistant in Excel.", + }, + ], + status: { type: "complete", reason: "stop" }, + }; + return; + } + + const latestUserMessage = [...messages] + .reverse() + .find((message) => message.role === "user"); + const latestUserText = extractMessageText(latestUserMessage); + if (!latestUserText) { + yield { + content: [{ type: "text", text: "Please enter a prompt first." }], + status: { type: "complete", reason: "stop" }, + }; + return; + } + + const parts: StreamingContentPart[] = []; + const history = toHistoryMessages(messages as ThreadMessage[]); + const toolRounds: ExcelToolRound[] = []; + + try { + for (let step = 0; step < MAX_TOOL_STEPS; step += 1) { + if (abortSignal.aborted) { + return; + } + + const latestContext = contextRef.current; + const contextSnapshot = getAssistantContextSnapshot({ + workbookReady: latestContext.isReady, + activeSheetId: latestContext.activeSheetId, + activeCell: latestContext.activeCell, + sheets: latestContext.sheets, + }); + + const stepResponse = await requestExcelChatStep( + { + threadId: threadIdRef.current, + messages: history, + model: selectedModelRef.current, + provider: inferProviderForModel(selectedModelRef.current), + reasoningEnabled: reasoningEnabledRef.current, + context: contextSnapshot, + toolRounds, + }, + abortSignal, + ); + + if (!stepResponse.ok) { + appendText(parts, stepResponse.error || "Assistant request failed."); + yield buildYieldPayload(parts, threadIdRef.current, true); + return; + } + + if (stepResponse.type === "assistant") { + appendText(parts, stepResponse.message || "Done."); + yield buildYieldPayload(parts, threadIdRef.current, true); + return; + } + + const executedResults: ExcelToolResult[] = []; + for (const toolCall of stepResponse.toolCalls) { + upsertToolCall(parts, toolCall.toolCallId, toolCall.toolName, toolCall.args); + yield buildYieldPayload(parts, threadIdRef.current, false); + + const toolResult = await latestContext.runExcel(async (context) => { + return executeExcelToolCall(context, toolCall); + }); + + setToolResult(parts, toolCall.toolCallId, toolCall.toolName, toolResult); + yield buildYieldPayload(parts, threadIdRef.current, false); + + executedResults.push({ + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + result: toolResult, + isError: + isRecord(toolResult) && typeof toolResult.success === "boolean" + ? toolResult.success === false + : false, + }); + } + + toolRounds.push({ + toolCalls: stepResponse.toolCalls as ExcelToolCall[], + toolResults: executedResults, + }); + + await latestContext.refreshSnapshot(); + } + + appendText( + parts, + "Stopped after reaching the maximum tool execution steps for this request.", + ); + yield buildYieldPayload(parts, threadIdRef.current, true); + } catch (error) { + appendText(parts, toErrorMessage(error)); + yield buildYieldPayload(parts, threadIdRef.current, true); + } + }, + }), + [], + ); + + const runtime = useLocalRuntime(chatAdapter, { maxSteps: 1 }); + + const handleNewChat = React.useCallback(() => { + runtime.thread.reset([]); + setThreadId(createThreadId()); + }, [runtime.thread]); + + const handleSignOut = React.useCallback(async () => { + if (isSigningOut) { + return; + } + + setAuthError(null); + setIsSigningOut(true); + try { + const { error } = await authClient.signOut(); + if (error) { + await fetch("/auth/sign-out", { + method: "POST", + credentials: "include", + }); + } + } catch { + try { + await fetch("/auth/sign-out", { + method: "POST", + credentials: "include", + }); + } catch { + // Ignore fallback failures and still reset local auth state. + } + } finally { + runtime.thread.reset([]); + setThreadId(createThreadId()); + setSignedInEmail(null); + setAuthStatus("unauthenticated"); + setAuthPendingProvider(null); + setIsSigningOut(false); + } + }, [isSigningOut, runtime.thread]); + + const handleNavigateToRange = React.useCallback( + (input: { range: string; sheetId: number | null }) => { + void runExcel(async (context) => { + const worksheets = context.workbook.worksheets; + let worksheet = worksheets.getActiveWorksheet(); + + if ( + typeof input.sheetId === "number" && + Number.isInteger(input.sheetId) && + input.sheetId > 0 + ) { + worksheets.load("items/position"); + await context.sync(); + + const target = worksheets.items.find( + (sheet) => sheet.position + 1 === input.sheetId, + ); + if (target) { + worksheet = target; + } + } + + worksheet.activate(); + worksheet.getRange(input.range).select(); + await context.sync(); + }) + .then(() => refreshSnapshot()) + .catch((error) => { + console.error("[excel-assistant] Failed to navigate to range", { + error: toErrorMessage(error), + range: input.range, + sheetId: input.sheetId, + }); + }); + }, + [refreshSnapshot, runExcel], + ); + const selectedModelLabel = getAssistantModelLabel(selectedModel); + + return ( + + + +
+
+
+
+

+ Spreadsheet Agent +

+

+ {ASSISTANT_TAGLINE} +

+
+ + {isReady ? "Workbook connected" : "Waiting for Office"} + + + {authStatus === "authenticated" && signedInEmail + ? signedInEmail + : activeCell + ? `Sheet ${activeSheetId ?? "-"} • ${activeCell.a1Address}` + : "No active selection yet"} + +
+
+
+ + + {authStatus === "authenticated" && ( + + )} +
+
+
+ + {authStatus === "loading" ? ( +
+
+ + Checking session... +
+
+ ) : authStatus === "unauthenticated" ? ( +
+
+

+ Sign in required +

+

+ Please sign in to use the assistant inside Excel. +

+
+ + +
+ {authError && ( +

+ {authError} +

+ )} + +
+
+ ) : ( + + + +
+ +
+
+
+ + +
+ + {EXCEL_STARTER_PROMPTS.map((prompt) => ( + + + + + {prompt} + + + + + {prompt} + + + ))} + +
+
+ {!isReady && ( +
+ Waiting for Office runtime... +
+ )} +
+
+ )} +
+
+
+
+ ); +} diff --git a/components/excel-addin/excel-chat-ui.tsx b/components/excel-addin/excel-chat-ui.tsx new file mode 100644 index 0000000..d2f0c55 --- /dev/null +++ b/components/excel-addin/excel-chat-ui.tsx @@ -0,0 +1,1352 @@ +"use client"; + +import type { + AssistantToolUIProps, + ToolCallMessagePartProps, +} from "@assistant-ui/react"; +import { + ComposerPrimitive, + MessagePrimitive, + useAssistantToolUI, + useAuiState, + useComposer, + useComposerRuntime, + useMessage, + useMessagePartText, + useThread, + useThreadRuntime, +} from "@assistant-ui/react"; +import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { uuidString } from "@rowsncolumns/utils"; +import { IconButton } from "@rowsncolumns/ui"; +import { + Check, + CheckCircle2, + ChevronDown, + ChevronsUpDown, + Copy, + Cpu, + Loader2, + Navigation, + SendHorizontal, + Sparkles, + Square, + Table2, + X, + XCircle, +} from "lucide-react"; +import * as React from "react"; +import remarkGfm from "remark-gfm"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { INITIAL_CREDITS, MIN_CREDITS_PER_RUN } from "@/lib/credits/pricing"; +import { cn } from "@/lib/utils"; +import { useExcelContext } from "@/components/excel-addin/excel-context"; + +const MARKDOWN_REMARK_PLUGINS = [remarkGfm]; +const TOOL_INPUT_UNAVAILABLE_MARKER = "__rnc_tool_input_unavailable__"; + +type ModelOption = { + value: string; + label: string; +}; + +type ModelOptionGroup = { + label: string; + options: ModelOption[]; +}; + +const MODEL_OPTION_GROUPS: ModelOptionGroup[] = [ + { + label: "OpenAI", + options: [ + { value: "gpt-5.4", label: "GPT-5.4" }, + { value: "gpt-5.4-mini", label: "GPT-5.4 Mini" }, + { value: "gpt-5.4-nano", label: "GPT-5.4 Nano" }, + { value: "gpt-5.2-chat-latest", label: "GPT-5.2 Chat" }, + { value: "gpt-5-mini", label: "GPT-5 Mini" }, + { value: "gpt-5-nano", label: "GPT-5 Nano" }, + { value: "gpt-4.1", label: "GPT-4.1" }, + { value: "gpt-4.1-mini", label: "GPT-4.1 Mini" }, + { value: "gpt-4.1-nano", label: "GPT-4.1 Nano" }, + { value: "o3", label: "o3" }, + { value: "o4-mini", label: "o4-mini" }, + ], + }, + { + label: "Anthropic", + options: [ + { value: "claude-opus-4-6", label: "Claude Opus 4.6" }, + { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" }, + { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" }, + { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, + { value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" }, + ], + }, +]; + +const MODEL_OPTIONS: ModelOption[] = MODEL_OPTION_GROUPS.flatMap( + (group) => group.options, +); +export const DEFAULT_ASSISTANT_MODEL = + MODEL_OPTION_GROUPS[0]?.options[0]?.value ?? "gpt-5.2-chat-latest"; +export const getAssistantModelLabel = (model: string) => + MODEL_OPTIONS.find((option) => option.value === model)?.label ?? model; + +const SPREADSHEET_TOOL_NAMES = [ + "spreadsheet_changeBatch", + "spreadsheet_createSheet", + "spreadsheet_updateSheet", + "spreadsheet_formatRange", + "spreadsheet_insertRows", + "spreadsheet_insertColumns", + "spreadsheet_queryRange", + "spreadsheet_setIterativeMode", + "spreadsheet_readDocument", + "spreadsheet_setRowColDimensions", + "spreadsheet_duplicateSheet", + "spreadsheet_deleteCells", + "spreadsheet_clearFormatting", + "spreadsheet_applyFill", + "spreadsheet_insertNote", + "spreadsheet_deleteRows", + "spreadsheet_deleteColumns", +] as const; + +type ToolCopy = { + running: string; + success: string; + failed: string; +}; + +const TOOL_UI_COPY: Record = { + spreadsheet_changeBatch: { + running: "Updating spreadsheet data", + success: "Updated spreadsheet data", + failed: "Failed to update spreadsheet data", + }, + spreadsheet_createSheet: { + running: "Creating sheet", + success: "Created sheet", + failed: "Failed to create sheet", + }, + spreadsheet_updateSheet: { + running: "Updating sheet settings", + success: "Updated sheet settings", + failed: "Failed to update sheet settings", + }, + spreadsheet_formatRange: { + running: "Applying formatting", + success: "Applied formatting", + failed: "Failed to apply formatting", + }, + spreadsheet_insertRows: { + running: "Inserting rows", + success: "Inserted rows", + failed: "Failed to insert rows", + }, + spreadsheet_insertColumns: { + running: "Inserting columns", + success: "Inserted columns", + failed: "Failed to insert columns", + }, + spreadsheet_queryRange: { + running: "Reading spreadsheet data", + success: "Read spreadsheet data", + failed: "Failed to read spreadsheet data", + }, + spreadsheet_setIterativeMode: { + running: "Updating iterative mode", + success: "Updated iterative mode", + failed: "Failed to update iterative mode", + }, + spreadsheet_readDocument: { + running: "Reading document", + success: "Read document", + failed: "Failed to read document", + }, + spreadsheet_setRowColDimensions: { + running: "Setting row/column dimensions", + success: "Set row/column dimensions", + failed: "Failed to set row/column dimensions", + }, + spreadsheet_duplicateSheet: { + running: "Duplicating sheet", + success: "Duplicated sheet", + failed: "Failed to duplicate sheet", + }, + spreadsheet_deleteCells: { + running: "Deleting cells", + success: "Deleted cells", + failed: "Failed to delete cells", + }, + spreadsheet_clearFormatting: { + running: "Clearing formatting", + success: "Cleared formatting", + failed: "Failed to clear formatting", + }, + spreadsheet_applyFill: { + running: "Applying fill", + success: "Applied fill", + failed: "Failed to apply fill", + }, + spreadsheet_insertNote: { + running: "Inserting note", + success: "Inserted note", + failed: "Failed to insert note", + }, + spreadsheet_deleteRows: { + running: "Deleting rows", + success: "Deleted rows", + failed: "Failed to delete rows", + }, + spreadsheet_deleteColumns: { + running: "Deleting columns", + success: "Deleted columns", + failed: "Failed to delete columns", + }, +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isUnavailableToolArgs = (value: unknown) => + isRecord(value) && value[TOOL_INPUT_UNAVAILABLE_MARKER] === true; + +const deepParseJsonValue = (value: unknown): unknown => { + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return deepParseJsonValue(parsed); + } catch { + return value; + } + } + + if (Array.isArray(value)) { + return value.map(deepParseJsonValue); + } + + if (isRecord(value)) { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = deepParseJsonValue(val); + } + return result; + } + + return value; +}; + +const getRangeFromParsedToolArgs = (value: unknown): string | null => { + if (!isRecord(value)) return null; + + if (typeof value.range === "string" && value.range.trim().length > 0) { + return value.range; + } + + if (isRecord(value.input)) { + const nestedRange = value.input.range; + if (typeof nestedRange === "string" && nestedRange.trim().length > 0) { + return nestedRange; + } + } + + return null; +}; + +const getSheetIdFromParsedToolArgs = (value: unknown): number | null => { + if (!isRecord(value)) return null; + + if (typeof value.sheetId === "number" && Number.isFinite(value.sheetId)) { + return value.sheetId; + } + + if (isRecord(value.input)) { + const nestedSheetId = value.input.sheetId; + if (typeof nestedSheetId === "number" && Number.isFinite(nestedSheetId)) { + return nestedSheetId; + } + } + + return null; +}; + +const getCreatedSheetIdFromToolResult = (value: unknown): number | null => { + if (!isRecord(value)) return null; + + if (typeof value.sheetId === "number" && Number.isFinite(value.sheetId)) { + return value.sheetId; + } + + if ( + isRecord(value.sheet) && + typeof value.sheet.sheetId === "number" && + Number.isFinite(value.sheet.sheetId) + ) { + return value.sheet.sheetId; + } + + return null; +}; + +type ParsedToolResult = { + success?: boolean; + error?: string; + range?: string; + [key: string]: unknown; +}; + +const extractParsedToolResult = (result: unknown): ParsedToolResult | null => { + if (!result) return null; + + if (isRecord(result)) { + const r = result; + if (r.kwargs && typeof r.kwargs === "object") { + const kwargs = r.kwargs as Record; + if (typeof kwargs.content === "string") { + try { + const parsed = JSON.parse(kwargs.content); + return isRecord(parsed) ? (parsed as ParsedToolResult) : null; + } catch { + return { error: kwargs.content }; + } + } + } + + if ("success" in r) { + return r as ParsedToolResult; + } + } + + if (typeof result === "string") { + try { + const parsed = JSON.parse(result); + return isRecord(parsed) ? (parsed as ParsedToolResult) : null; + } catch { + return null; + } + } + + return null; +}; + +const formatToolNameFallback = (toolName: string) => + toolName + .replace(/^spreadsheet_/, "") + .replace(/_/g, " ") + .trim(); + +const getToolCopy = (toolName: string): ToolCopy => { + const mapped = TOOL_UI_COPY[toolName]; + if (mapped) return mapped; + + const fallbackName = formatToolNameFallback(toolName) || toolName; + return { + running: `Running ${fallbackName}`, + success: `Completed ${fallbackName}`, + failed: `Failed ${fallbackName}`, + }; +}; + +function TypingIndicator() { + return ( + + + + + + ); +} + +function MarkdownText() { + return ; +} + +function AssistantTextPart() { + const textPart = useMessagePartText(); + if (!textPart.text?.trim()) return null; + + return ( + + +
+ +
+
+
+ ); +} + +function ReasoningBlock({ + children, + forceOpen, +}: { + children: React.ReactNode; + forceOpen: boolean; +}) { + const [isOpen, setIsOpen] = React.useState(forceOpen); + + React.useEffect(() => { + setIsOpen(forceOpen); + }, [forceOpen]); + + return ( + + + + Thinking + + + + {children} + + + ); +} + +function ToolCallDisplay({ + toolName, + args, + result, + onNavigateToRange, +}: { + toolName: string; + args: unknown; + result?: unknown; + onNavigateToRange?: (input: { range: string; sheetId: number | null }) => void; +}) { + const [isOpen, setIsOpen] = React.useState(false); + const [copiedTab, setCopiedTab] = React.useState<"input" | "output" | null>( + null, + ); + const hasResult = result !== undefined; + + const handleCopy = React.useCallback( + async (content: string, tab: "input" | "output") => { + try { + await navigator.clipboard.writeText(content); + setCopiedTab(tab); + setTimeout(() => setCopiedTab(null), 2000); + } catch { + // Ignore clipboard errors. + } + }, + [], + ); + + const extractedResult = React.useMemo( + () => extractParsedToolResult(result), + [result], + ); + + const isParsedError = + extractedResult?.success === false || + (typeof extractedResult?.error === "string" && + extractedResult.error.trim().length > 0 && + extractedResult.success !== true); + const isError = isParsedError; + const errorMessage = extractedResult?.error; + const isComplete = hasResult && !isError; + const isRunning = !hasResult; + const toolCopy = React.useMemo(() => getToolCopy(toolName), [toolName]); + const titleText = isRunning + ? toolCopy.running + : isError + ? toolCopy.failed + : toolCopy.success; + const parsedArgs = React.useMemo(() => deepParseJsonValue(args), [args]); + const rangeFromArgs = getRangeFromParsedToolArgs(parsedArgs); + const rangeFromResult = + typeof extractedResult?.range === "string" ? extractedResult.range : null; + const sheetIdFromArgs = getSheetIdFromParsedToolArgs(parsedArgs); + const sheetIdFromResult = getSheetIdFromParsedToolArgs(extractedResult); + const rangeForNavigation = rangeFromArgs || rangeFromResult; + const sheetIdForNavigation = sheetIdFromArgs ?? sheetIdFromResult ?? null; + const canNavigateToRange = Boolean(onNavigateToRange && rangeForNavigation); + + const navigateToRange = React.useCallback(() => { + if (!onNavigateToRange || !rangeForNavigation) return; + onNavigateToRange({ + range: rangeForNavigation, + sheetId: sheetIdForNavigation, + }); + }, [ + onNavigateToRange, + rangeForNavigation, + sheetIdForNavigation, + ]); + + const range = + rangeFromArgs || + rangeFromResult || + (toolName === "spreadsheet_changeBatch" ? "cells" : ""); + const explanation = React.useMemo(() => { + if (!isRecord(parsedArgs)) return null; + + const parsedInput = parsedArgs.input; + if ( + isRecord(parsedInput) && + typeof parsedInput.explanation === "string" && + parsedInput.explanation.trim().length > 0 + ) { + return parsedInput.explanation.trim(); + } + + if ( + typeof parsedArgs.explanation === "string" && + parsedArgs.explanation.trim().length > 0 + ) { + return parsedArgs.explanation.trim(); + } + + return null; + }, [parsedArgs]); + + const formattedArgs = React.useMemo(() => { + if (isUnavailableToolArgs(parsedArgs)) { + return "Input unavailable: tool call failed before execution and runtime did not emit tool arguments."; + } + + try { + const serialized = JSON.stringify(parsedArgs, null, 2); + return typeof serialized === "string" ? serialized : String(args); + } catch { + return String(args); + } + }, [args, parsedArgs]); + + return ( + + + {isRunning ? ( + + ) : isError ? ( + + ) : ( + + )} + +
+
+ {titleText} + {range && ({range})} + . {explanation} +
+
+ + {isComplete && canNavigateToRange && ( + { + event.preventDefault(); + event.stopPropagation(); + navigateToRange(); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + navigateToRange(); + }} + className="ml-auto shrink-0 cursor-pointer rounded p-1 text-green-600 hover:bg-green-100" + > + + + )} + + +
+ + {isError && ( +
+
Error
+
+ {errorMessage || "Unknown error"} +
+
+ )} + + + + Input + + + Output + + + +
+ +
+                {formattedArgs}
+              
+
+
+ +
+ {hasResult && ( + + )} + {hasResult ? ( +
+                  {JSON.stringify(extractedResult, null, 2)}
+                
+ ) : ( +
+ Waiting for result... +
+ )} +
+
+
+
+
+ ); +} + +function ToolUIRegistration({ + toolName, + onNavigateToRange, +}: { + toolName: (typeof SPREADSHEET_TOOL_NAMES)[number]; + onNavigateToRange?: (input: { range: string; sheetId: number | null }) => void; +}) { + function SpreadsheetCreateSheetSideEffect({ result }: { result?: unknown }) { + const { runExcel, refreshSnapshot } = useExcelContext(); + const hasResult = result !== undefined; + const parsedResult = React.useMemo( + () => extractParsedToolResult(result), + [result], + ); + const createdSheetId = React.useMemo( + () => getCreatedSheetIdFromToolResult(parsedResult), + [parsedResult], + ); + const hasResultRef = React.useRef(hasResult); + + React.useEffect(() => { + const hadResult = hasResultRef.current; + hasResultRef.current = hasResult; + + if (hadResult || !hasResult || createdSheetId === null) { + return; + } + + void runExcel(async (context) => { + const worksheets = context.workbook.worksheets; + worksheets.load("items/position"); + await context.sync(); + + const target = worksheets.items.find( + (sheet) => sheet.position + 1 === createdSheetId, + ); + if (!target) return; + + target.activate(); + await context.sync(); + }) + .then(() => refreshSnapshot()) + .catch(() => { + // Ignore side effect errors. + }); + }, [createdSheetId, hasResult, refreshSnapshot, runExcel]); + + return null; + } + + const toolUI = React.useMemo< + AssistantToolUIProps, unknown> + >( + () => ({ + toolName, + render( + toolPartProps: ToolCallMessagePartProps, unknown>, + ) { + return ( +
+ {toolPartProps.toolName === "spreadsheet_createSheet" && ( + + )} + +
+ ); + }, + }), + [onNavigateToRange, toolName], + ); + + useAssistantToolUI(toolUI); + return null; +} + +export function ToolUIRegistry({ + onNavigateToRange, +}: { + onNavigateToRange?: (input: { range: string; sheetId: number | null }) => void; +}) { + return SPREADSHEET_TOOL_NAMES.map((toolName) => ( + + )); +} + +export function AssistantMessage() { + const role = useMessage((message) => message.role); + const userMessageText = useMessage((message) => + message.role !== "user" + ? "" + : message.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("") + .trim(), + ); + const isLastMessage = useAuiState(({ message }) => message.isLast); + const isThreadRunning = useThread((thread) => thread.isRunning); + const isComplete = useMessage( + (message) => message.status?.type === "complete", + ); + const hasAnyVisibleReasoning = useAuiState(({ message }) => + message.content.some( + (part) => + part.type === "reasoning" && + typeof part.text === "string" && + part.text.trim().length > 0, + ), + ); + const hasAnyToolCall = useAuiState(({ message }) => + message.content.some((part) => part.type === "tool-call"), + ); + const hasAnyVisibleText = useAuiState(({ message }) => + message.content.some( + (part) => + part.type === "text" && + typeof part.text === "string" && + part.text.trim().length > 0, + ), + ); + const showTypingIndicatorBeforeText = + role === "assistant" && + isLastMessage && + isThreadRunning && + !hasAnyVisibleReasoning && + !hasAnyToolCall && + !hasAnyVisibleText; + const showRunningLoadingSpinner = + role === "assistant" && + isLastMessage && + isThreadRunning && + !showTypingIndicatorBeforeText && + (hasAnyVisibleReasoning || hasAnyToolCall || hasAnyVisibleText); + const [isUserCopySuccess, setIsUserCopySuccess] = React.useState(false); + const handleCopyUserMessage = React.useCallback(async () => { + if (!userMessageText) return; + try { + await navigator.clipboard.writeText(userMessageText); + setIsUserCopySuccess(true); + setTimeout(() => setIsUserCopySuccess(false), 1500); + } catch { + // Ignore clipboard failures. + } + }, [userMessageText]); + + return ( + +
+ {role === "assistant" && + (hasAnyVisibleReasoning || hasAnyVisibleText || hasAnyToolCall) && ( + ( +
+ +
+ ), + ReasoningGroup: ({ children }: React.PropsWithChildren) => ( +
+ + {children} + +
+ ), + ToolGroup: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), + }} + /> + )} + {role === "assistant" && showTypingIndicatorBeforeText && ( + + +
+ +
+
+
+ )} + {role === "assistant" && showRunningLoadingSpinner && ( + + +
+ +
+
+
+ )} + {role === "user" && ( +
+ + +
+ +
+
+
+ + {isUserCopySuccess ? ( + + ) : ( + + )} + +
+ )} +
+
+ ); +} + +type QueuedComposerMessage = { + id: string; + text: string; +}; + +export type AssistantComposerProps = { + selectedModel: string; + selectedModelLabel: string; + isModelPickerOpen: boolean; + setIsModelPickerOpen: (open: boolean) => void; + setSelectedModel: (model: string) => void; + reasoningEnabled: boolean; + setReasoningEnabled: React.Dispatch>; + reasoningEnabledRef: React.MutableRefObject; + forceCompactHeader?: boolean; + remainingCredits: number | null; + isUnlimitedCredits: boolean; + isCreditsLoading: boolean; +}; + +const MODEL_PICKER_HIDE_WIDTH = 460; + +export function AssistantComposer({ + selectedModel, + selectedModelLabel, + isModelPickerOpen, + setIsModelPickerOpen, + setSelectedModel, + reasoningEnabled, + setReasoningEnabled, + reasoningEnabledRef, + forceCompactHeader = false, + remainingCredits, + isUnlimitedCredits, + isCreditsLoading, +}: AssistantComposerProps) { + const composerFooterRef = React.useRef(null); + const [isComposerCompact, setIsComposerCompact] = + React.useState(forceCompactHeader); + const handleSelectModel = React.useCallback( + (model: string) => { + setSelectedModel(model); + setIsModelPickerOpen(false); + }, + [setIsModelPickerOpen, setSelectedModel], + ); + const composerRuntime = useComposerRuntime(); + const threadRuntime = useThreadRuntime(); + const isThreadRunning = useThread((thread) => thread.isRunning); + const hasCredits = + isUnlimitedCredits || + remainingCredits === null || + remainingCredits >= MIN_CREDITS_PER_RUN; + const canSendFromComposer = useComposer( + (composer) => composer.isEditing && !composer.isEmpty, + ); + const [queuedMessages, setQueuedMessages] = React.useState< + QueuedComposerMessage[] + >([]); + const queuedMessagesRef = React.useRef([]); + const hasQueuedDispatchRef = React.useRef(false); + + React.useEffect(() => { + queuedMessagesRef.current = queuedMessages; + }, [queuedMessages]); + + React.useEffect(() => { + if (forceCompactHeader) { + setIsComposerCompact(true); + return; + } + + const footer = composerFooterRef.current; + if (!footer) return; + + const updateComposerWidthState = () => { + const { width } = footer.getBoundingClientRect(); + setIsComposerCompact(width < MODEL_PICKER_HIDE_WIDTH); + }; + + updateComposerWidthState(); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateComposerWidthState); + return () => { + window.removeEventListener("resize", updateComposerWidthState); + }; + } + + const resizeObserver = new ResizeObserver(() => { + updateComposerWidthState(); + }); + resizeObserver.observe(footer); + + return () => { + resizeObserver.disconnect(); + }; + }, [forceCompactHeader]); + + const enqueueCurrentComposerMessage = React.useCallback(() => { + const message = composerRuntime.getState().text.trim(); + if (!message) return false; + + setQueuedMessages((previous) => [ + { id: uuidString(), text: message }, + ...previous, + ]); + composerRuntime.setText(""); + return true; + }, [composerRuntime]); + + const handleRemoveQueuedMessage = React.useCallback((messageId: string) => { + setQueuedMessages((previous) => + previous.filter((queuedMessage) => queuedMessage.id !== messageId), + ); + }, []); + + const handleSendOrQueue = React.useCallback(() => { + if (isThreadRunning) { + enqueueCurrentComposerMessage(); + return; + } + + if (!canSendFromComposer || !hasCredits) return; + composerRuntime.send(); + }, [ + canSendFromComposer, + composerRuntime, + enqueueCurrentComposerMessage, + hasCredits, + isThreadRunning, + ]); + + const handleStopRun = React.useCallback(() => { + if (!isThreadRunning) return; + threadRuntime.cancelRun(); + }, [isThreadRunning, threadRuntime]); + + const handleQueueOnEnter = React.useCallback( + (event: React.KeyboardEvent) => { + if (!isThreadRunning) return; + if ( + event.nativeEvent.isComposing || + event.key !== "Enter" || + event.shiftKey + ) { + return; + } + + event.preventDefault(); + enqueueCurrentComposerMessage(); + }, + [enqueueCurrentComposerMessage, isThreadRunning], + ); + + React.useEffect(() => { + if (isThreadRunning) { + hasQueuedDispatchRef.current = false; + return; + } + + if (hasQueuedDispatchRef.current) return; + + const nextMessage = queuedMessagesRef.current.at(-1); + if (!nextMessage) return; + + const remainingMessages = queuedMessagesRef.current.slice(0, -1); + hasQueuedDispatchRef.current = true; + queuedMessagesRef.current = remainingMessages; + setQueuedMessages(remainingMessages); + threadRuntime.append({ + content: [{ type: "text", text: nextMessage.text }], + runConfig: composerRuntime.getState().runConfig, + startRun: true, + }); + }, [composerRuntime, isThreadRunning, threadRuntime]); + + return ( + + {queuedMessages.length > 0 && ( +
+
+ {queuedMessages.map((queuedMessage) => ( +
+

+ {queuedMessage.text} +

+ handleRemoveQueuedMessage(queuedMessage.id)} + className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/5 hover:text-foreground" + title="Remove queued message" + aria-label="Remove queued message" + > + + +
+ ))} +
+
+ )} +
+ +
+
+
+ {isComposerCompact ? ( + + + + + + + + + + + No model found. + {MODEL_OPTION_GROUPS.map((group) => ( + + {group.options.map((option) => ( + handleSelectModel(option.value)} + className="text-xs" + > + + {option.label} + + ))} + + ))} + + + + + ) : ( + + )} + + setReasoningEnabled((previous) => { + const next = !previous; + reasoningEnabledRef.current = next; + return next; + }) + } + className={cn( + "inline-flex h-8 w-8 items-center justify-center rounded-lg border shadow-none transition", + reasoningEnabled + ? "border-(--panel-border-strong) bg-(--assistant-chip-hover) text-foreground hover:bg-(--assistant-suggestion-hover) hover:text-foreground" + : "rnc-assistant-chip border-(--panel-border) bg-(--assistant-chip-bg) text-(--muted-foreground) hover:bg-(--assistant-chip-hover) hover:text-foreground", + )} + aria-label={`Reasoning ${reasoningEnabled ? "on" : "off"}`} + title={reasoningEnabled ? "Reasoning On" : "Reasoning Off"} + > + + +
+
+ + {isCreditsLoading + ? "Credits ..." + : isUnlimitedCredits + ? "Credits Unlimited" + : `Credits ${remainingCredits ?? 0}/${INITIAL_CREDITS}`} + + {isThreadRunning && ( + + + + )} + +
+
+
+ ); +} diff --git a/components/excel-addin/excel-context.tsx b/components/excel-addin/excel-context.tsx new file mode 100644 index 0000000..9ecace3 --- /dev/null +++ b/components/excel-addin/excel-context.tsx @@ -0,0 +1,183 @@ +"use client"; + +import * as React from "react"; + +type ExcelSheetSummary = { + sheetId: number; + excelSheetId: string; + name: string; +}; + +export type ExcelContextSnapshot = { + isReady: boolean; + activeSheetId: number | null; + activeCell: { + rowIndex: number; + columnIndex: number; + a1Address: string; + } | null; + sheets: ExcelSheetSummary[]; +}; + +type ExcelContextValue = ExcelContextSnapshot & { + runExcel: ( + callback: (context: Excel.RequestContext) => Promise, + ) => Promise; + refreshSnapshot: () => Promise; +}; + +const ExcelContext = React.createContext(null); + +const INITIAL_SNAPSHOT: ExcelContextSnapshot = { + isReady: false, + activeSheetId: null, + activeCell: null, + sheets: [], +}; + +const toErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +export function ExcelProvider({ children }: { children: React.ReactNode }) { + const [snapshot, setSnapshot] = React.useState( + INITIAL_SNAPSHOT, + ); + const isMountedRef = React.useRef(false); + + React.useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const runExcel = React.useCallback( + async (callback: (context: Excel.RequestContext) => Promise) => { + return Excel.run(callback); + }, + [], + ); + + const refreshSnapshot = React.useCallback(async () => { + if (typeof Office === "undefined" || typeof Excel === "undefined") { + return; + } + + try { + await Excel.run(async (context) => { + const worksheets = context.workbook.worksheets; + const activeWorksheet = worksheets.getActiveWorksheet(); + const activeCell = context.workbook.getActiveCell(); + + worksheets.load("items/id,items/name,items/position"); + activeWorksheet.load("position"); + activeCell.load("address,rowIndex,columnIndex"); + await context.sync(); + + const sheets = worksheets.items + .slice() + .sort((a, b) => a.position - b.position) + .map((sheet) => ({ + sheetId: sheet.position + 1, + excelSheetId: sheet.id, + name: sheet.name, + })); + + if (!isMountedRef.current) { + return; + } + + setSnapshot({ + isReady: true, + activeSheetId: activeWorksheet.position + 1, + activeCell: { + rowIndex: activeCell.rowIndex + 1, + columnIndex: activeCell.columnIndex + 1, + a1Address: activeCell.address, + }, + sheets, + }); + }); + } catch (error) { + console.error("[excel-context] Failed to refresh snapshot", { + error: toErrorMessage(error), + }); + } + }, []); + + React.useEffect(() => { + let cancelled = false; + let intervalId: number | null = null; + + const tryInitialize = async () => { + if (cancelled) { + return true; + } + + if (typeof Office === "undefined" || typeof Excel === "undefined") { + return false; + } + + try { + await Office.onReady(); + if (cancelled || !isMountedRef.current) { + return true; + } + await refreshSnapshot(); + return true; + } catch (error) { + if (cancelled) { + return true; + } + console.error("[excel-context] Office initialization failed", { + error: toErrorMessage(error), + }); + return; + } + }; + + const initialize = async () => { + const initialized = await tryInitialize(); + if (initialized || cancelled) { + return; + } + + intervalId = window.setInterval(() => { + void tryInitialize().then((didInitialize) => { + if (didInitialize && intervalId !== null) { + window.clearInterval(intervalId); + intervalId = null; + } + }); + }, 400); + }; + + void initialize(); + + return () => { + cancelled = true; + if (intervalId !== null) { + window.clearInterval(intervalId); + } + }; + }, [refreshSnapshot]); + + const value = React.useMemo( + () => ({ + ...snapshot, + runExcel, + refreshSnapshot, + }), + [refreshSnapshot, runExcel, snapshot], + ); + + return {children}; +} + +export const useExcelContext = () => { + const context = React.useContext(ExcelContext); + if (!context) { + throw new Error("useExcelContext must be used inside ExcelProvider."); + } + return context; +}; diff --git a/components/excel-addin/excel-tools.ts b/components/excel-addin/excel-tools.ts new file mode 100644 index 0000000..4fde641 --- /dev/null +++ b/components/excel-addin/excel-tools.ts @@ -0,0 +1,455 @@ +"use client"; + +import type { ExcelToolCall } from "@/lib/chat/excel-protocol"; + +type ToolResult = { + success: boolean; + message?: string; + error?: string; + [key: string]: unknown; +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const asNumber = (value: unknown) => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const asString = (value: unknown) => + typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + +const toErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const getWorksheetBySheetId = async ( + context: Excel.RequestContext, + sheetId: number | undefined, +) => { + const worksheets = context.workbook.worksheets; + if (sheetId === undefined) { + return worksheets.getActiveWorksheet(); + } + + worksheets.load("items/id,items/name,items/position"); + await context.sync(); + + const candidate = worksheets.items.find( + (sheet) => + sheet.position + 1 === sheetId || + Number.parseInt(sheet.id, 10) === sheetId || + sheet.name === String(sheetId), + ); + + return candidate ?? worksheets.getActiveWorksheet(); +}; + +const parseCells2d = (input: unknown) => { + if (typeof input === "string") { + try { + return JSON.parse(input); + } catch { + return null; + } + } + return input; +}; + +const parseRangeStyleCells = (input: unknown) => { + const parsed = parseCells2d(input); + if (!Array.isArray(parsed) || parsed.length === 0) return null; + const firstRow = parsed[0]; + if (!Array.isArray(firstRow) || firstRow.length === 0) return null; + const firstCell = firstRow[0]; + if (!isRecord(firstCell)) return null; + const styles = firstCell.cellStyles; + if (!isRecord(styles)) return null; + return styles; +}; + +const normalizeColor = (value: unknown) => { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + return undefined; +}; + +const handleSpreadsheetChangeBatch = async ( + context: Excel.RequestContext, + args: unknown, +): Promise => { + const input = isRecord(args) ? args : {}; + const rangeAddress = asString(input.range); + const sheetId = asNumber(input.sheetId); + if (!rangeAddress) { + return { success: false, error: "range is required." }; + } + + const cells = parseCells2d(input.cells); + if (!Array.isArray(cells)) { + return { success: false, error: "cells must be a 2D array." }; + } + + const worksheet = await getWorksheetBySheetId(context, sheetId); + const range = worksheet.getRange(rangeAddress); + + const values = cells.map((row) => { + if (!Array.isArray(row)) return [null]; + return row.map((cell) => { + if (!isRecord(cell)) return null; + const formula = asString(cell.formula); + if (formula) { + return formula.startsWith("=") ? formula : `=${formula}`; + } + if (!("value" in cell)) return null; + const value = cell.value; + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + return String(value); + }); + }); + + range.values = values as (string | number | boolean | null)[][]; + await context.sync(); + + return { + success: true, + message: `Updated range ${rangeAddress}.`, + range: rangeAddress, + }; +}; + +const handleSpreadsheetQueryRange = async ( + context: Excel.RequestContext, + args: unknown, +): Promise => { + const input = isRecord(args) ? args : {}; + const items = Array.isArray(input.items) ? input.items : []; + if (items.length === 0) { + return { success: false, error: "items[] is required." }; + } + + const worksheet = await getWorksheetBySheetId(context, undefined); + const ranges = items.flatMap((item) => { + if (!isRecord(item)) return []; + const range = asString(item.range); + const layer = asString(item.layer) ?? "values"; + if (!range) return []; + const ref = worksheet.getRange(range); + if (layer === "formatting") { + ref.load([ + "address", + "format/fill/color", + "format/font/bold", + "format/font/color", + "format/font/italic", + "format/font/size", + "format/horizontalAlignment", + "format/verticalAlignment", + ]); + } else { + ref.load(["address", "values", "formulas", "text", "numberFormat"]); + } + return [{ range, layer, ref }]; + }); + + if (ranges.length === 0) { + return { success: false, error: "No valid query items found." }; + } + + await context.sync(); + + const resultItems = ranges.map(({ range, layer, ref }) => { + if (layer === "formatting") { + return { + range, + layer, + formatting: { + fillColor: ref.format.fill.color, + fontColor: ref.format.font.color, + fontBold: ref.format.font.bold, + fontItalic: ref.format.font.italic, + fontSize: ref.format.font.size, + horizontalAlignment: ref.format.horizontalAlignment, + verticalAlignment: ref.format.verticalAlignment, + }, + }; + } + + return { + range, + layer, + values: ref.values, + formulas: ref.formulas, + text: ref.text, + numberFormat: ref.numberFormat, + }; + }); + + return { + success: true, + items: resultItems, + }; +}; + +const handleSpreadsheetReadDocument = async ( + context: Excel.RequestContext, + args: unknown, +): Promise => { + const input = isRecord(args) ? args : {}; + const rangeAddress = asString(input.range); + const sheetId = asNumber(input.sheetId); + + const worksheet = await getWorksheetBySheetId(context, sheetId); + worksheet.load(["name", "position", "id"]); + + const range = rangeAddress + ? worksheet.getRange(rangeAddress) + : worksheet.getUsedRangeOrNullObject(); + range.load(["address", "values", "formulas", "text", "numberFormat"]); + + await context.sync(); + + if (!rangeAddress && range.isNullObject) { + return { + success: true, + message: "Sheet is empty.", + sheet: { + sheetId: worksheet.position + 1, + name: worksheet.name, + excelSheetId: worksheet.id, + }, + range: null, + values: [], + }; + } + + return { + success: true, + sheet: { + sheetId: worksheet.position + 1, + name: worksheet.name, + excelSheetId: worksheet.id, + }, + range: range.address, + values: range.values, + formulas: range.formulas, + text: range.text, + numberFormat: range.numberFormat, + }; +}; + +const handleSpreadsheetCreateSheet = async ( + context: Excel.RequestContext, + args: unknown, +): Promise => { + const input = isRecord(args) ? args : {}; + const sheetSpec = isRecord(input.sheetSpec) ? input.sheetSpec : {}; + + const title = asString(sheetSpec.title); + const worksheet = context.workbook.worksheets.add(title); + + if (sheetSpec.hidden === true) { + worksheet.visibility = Excel.SheetVisibility.hidden; + } + if (sheetSpec.hidden === false) { + worksheet.visibility = Excel.SheetVisibility.visible; + } + + const frozenRows = asNumber(sheetSpec.frozenRowCount); + if (frozenRows && frozenRows > 0) { + worksheet.freezePanes.freezeRows(frozenRows); + } + const frozenColumns = asNumber(sheetSpec.frozenColumnCount); + if (frozenColumns && frozenColumns > 0) { + worksheet.freezePanes.freezeColumns(frozenColumns); + } + + const tabColor = normalizeColor(sheetSpec.tabColor); + if (tabColor) { + worksheet.tabColor = tabColor; + } + + worksheet.load(["id", "name", "position"]); + await context.sync(); + + return { + success: true, + message: `Created sheet "${worksheet.name}".`, + sheetId: worksheet.position + 1, + excelSheetId: worksheet.id, + title: worksheet.name, + }; +}; + +const handleSpreadsheetUpdateSheet = async ( + context: Excel.RequestContext, + args: unknown, +): Promise => { + const input = isRecord(args) ? args : {}; + const sheetId = asNumber(input.sheetId); + const sheetSpec = isRecord(input.sheetSpec) ? input.sheetSpec : {}; + + const worksheet = await getWorksheetBySheetId(context, sheetId); + + const title = asString(sheetSpec.title); + if (title) { + worksheet.name = title; + } + + if (sheetSpec.hidden === true) { + worksheet.visibility = Excel.SheetVisibility.hidden; + } + if (sheetSpec.hidden === false) { + worksheet.visibility = Excel.SheetVisibility.visible; + } + + const frozenRows = asNumber(sheetSpec.frozenRowCount); + const frozenColumns = asNumber(sheetSpec.frozenColumnCount); + if (frozenRows && frozenRows > 0) { + worksheet.freezePanes.freezeRows(frozenRows); + } + if (frozenColumns && frozenColumns > 0) { + worksheet.freezePanes.freezeColumns(frozenColumns); + } + + const tabColor = normalizeColor(sheetSpec.tabColor); + if (tabColor) { + worksheet.tabColor = tabColor; + } + + worksheet.load(["name", "position", "id"]); + await context.sync(); + + return { + success: true, + message: `Updated sheet "${worksheet.name}".`, + sheetId: worksheet.position + 1, + excelSheetId: worksheet.id, + }; +}; + +const handleSpreadsheetFormatRange = async ( + context: Excel.RequestContext, + args: unknown, +): Promise => { + const input = isRecord(args) ? args : {}; + const rangeAddress = asString(input.range); + const sheetId = asNumber(input.sheetId); + if (!rangeAddress) { + return { success: false, error: "range is required." }; + } + + const worksheet = await getWorksheetBySheetId(context, sheetId); + const range = worksheet.getRange(rangeAddress); + const style = parseRangeStyleCells(input.cells); + + if (!style) { + return { + success: false, + error: "cells with cellStyles are required for spreadsheet_formatRange.", + }; + } + + const backgroundColor = normalizeColor(style.backgroundColor); + if (backgroundColor) { + range.format.fill.color = backgroundColor; + } + + const textFormat = isRecord(style.textFormat) ? style.textFormat : {}; + const fontColor = normalizeColor(textFormat.color); + if (fontColor) { + range.format.font.color = fontColor; + } + const fontSize = asNumber(textFormat.fontSize); + if (fontSize && fontSize > 0) { + range.format.font.size = fontSize; + } + if (typeof textFormat.bold === "boolean") { + range.format.font.bold = textFormat.bold; + } + if (typeof textFormat.italic === "boolean") { + range.format.font.italic = textFormat.italic; + } + if (typeof textFormat.underline === "boolean") { + range.format.font.underline = textFormat.underline + ? Excel.RangeUnderlineStyle.single + : Excel.RangeUnderlineStyle.none; + } + + const horizontalAlignment = asString(style.horizontalAlignment); + if ( + horizontalAlignment === "left" || + horizontalAlignment === "center" || + horizontalAlignment === "right" + ) { + range.format.horizontalAlignment = + horizontalAlignment === "left" + ? "Left" + : horizontalAlignment === "center" + ? "Center" + : "Right"; + } + + const verticalAlignment = asString(style.verticalAlignment); + if ( + verticalAlignment === "top" || + verticalAlignment === "middle" || + verticalAlignment === "bottom" + ) { + range.format.verticalAlignment = + verticalAlignment === "top" + ? "Top" + : verticalAlignment === "middle" + ? "Center" + : "Bottom"; + } + + await context.sync(); + + return { + success: true, + message: `Formatted range ${rangeAddress}.`, + range: rangeAddress, + }; +}; + +const EXECUTORS: Record< + string, + (context: Excel.RequestContext, args: unknown) => Promise +> = { + spreadsheet_changeBatch: handleSpreadsheetChangeBatch, + spreadsheet_queryRange: handleSpreadsheetQueryRange, + spreadsheet_readDocument: handleSpreadsheetReadDocument, + spreadsheet_createSheet: handleSpreadsheetCreateSheet, + spreadsheet_updateSheet: handleSpreadsheetUpdateSheet, + spreadsheet_formatRange: handleSpreadsheetFormatRange, +}; + +export const executeExcelToolCall = async ( + context: Excel.RequestContext, + call: ExcelToolCall, +) => { + const executor = EXECUTORS[call.toolName]; + if (!executor) { + return { + success: false, + error: `Tool "${call.toolName}" is not implemented in the Excel add-in yet.`, + unsupportedTool: true, + }; + } + + try { + return await executor(context, call.args); + } catch (error) { + return { + success: false, + error: toErrorMessage(error), + }; + } +}; diff --git a/components/workspace-assistant.tsx b/components/workspace-assistant.tsx index f081405..8131552 100644 --- a/components/workspace-assistant.tsx +++ b/components/workspace-assistant.tsx @@ -245,10 +245,13 @@ const MODEL_OPTION_GROUPS: ModelOptionGroup[] = [ const DEFAULT_MODEL = MODEL_OPTION_GROUPS[0]?.options[0]?.value ?? "gpt-5.2-chat-latest"; +export const DEFAULT_ASSISTANT_MODEL = DEFAULT_MODEL; const MODEL_OPTIONS: ModelOption[] = MODEL_OPTION_GROUPS.flatMap( (group) => group.options, ); +export const getAssistantModelLabel = (model: string) => + MODEL_OPTIONS.find((option) => option.value === model)?.label ?? model; const MODEL_OPTION_VALUES = new Set( MODEL_OPTIONS.map((option) => option.value), ); @@ -404,7 +407,8 @@ const useThreadIdFromUrl = () => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const initialSessionId = searchParams.get("session_id")?.trim() || null; + const safePathname = pathname || "/"; + const initialSessionId = searchParams?.get("session_id")?.trim() || null; const [threadId, setThreadId] = React.useState( () => initialSessionId ?? uuidString(), ); @@ -413,7 +417,7 @@ const useThreadIdFromUrl = () => { ); React.useEffect(() => { - const currentSessionId = searchParams.get("session_id")?.trim() || null; + const currentSessionId = searchParams?.get("session_id")?.trim() || null; setPersistInUrl(currentSessionId !== null); if (!currentSessionId) { return; @@ -428,8 +432,8 @@ const useThreadIdFromUrl = () => { const pushSessionIdToHistory = React.useCallback( (nextSessionId: string | null) => { - const params = new URLSearchParams(searchParams.toString()); - const currentSessionId = searchParams.get("session_id")?.trim() || null; + const params = new URLSearchParams(searchParams?.toString() || ""); + const currentSessionId = searchParams?.get("session_id")?.trim() || null; if (nextSessionId === null) { if (!currentSessionId) { @@ -444,10 +448,12 @@ const useThreadIdFromUrl = () => { } const nextQuery = params.toString(); - const nextUrl = nextQuery ? `${pathname}?${nextQuery}` : pathname; + const nextUrl = nextQuery + ? `${safePathname}?${nextQuery}` + : safePathname; router.push(nextUrl, { scroll: false }); }, - [pathname, router, searchParams], + [router, safePathname, searchParams], ); const markThreadStarted = React.useCallback(() => { @@ -3107,7 +3113,7 @@ const AssistantDebugAccessContext = React.createContext<{ isAdmin: boolean }>({ isAdmin: false, }); -function AssistantComposer({ +export function AssistantComposer({ selectedModel, selectedModelLabel, isModelPickerOpen, diff --git a/lib/auth/cookie-compat.ts b/lib/auth/cookie-compat.ts index 9c292cd..f4edf88 100644 --- a/lib/auth/cookie-compat.ts +++ b/lib/auth/cookie-compat.ts @@ -67,33 +67,10 @@ function normalizeNeonAuthSetCookie(setCookieHeader: string): string { return setCookieHeader; } - const parts = setCookieHeader - .split(";") - .map((part) => part.trim()) - .filter(Boolean); - - if (parts.length === 0) { - return setCookieHeader; - } - - const normalizedParts: string[] = []; - for (const part of parts) { - if (/^partitioned$/i.test(part)) { - continue; - } - - if (/^samesite=/i.test(part)) { - const sameSiteValue = part.slice(part.indexOf("=") + 1).trim().toLowerCase(); - if (sameSiteValue === "none") { - normalizedParts.push("SameSite=Lax"); - continue; - } - } - - normalizedParts.push(part); - } - - return normalizedParts.join("; "); + // Preserve upstream cookie attributes as-is. + // Excel taskpane runs in a third-party iframe context and requires + // `SameSite=None; Secure` (and optionally `Partitioned`) cookies. + return setCookieHeader; } function didSetCookieHeadersChange( diff --git a/lib/chat/excel-protocol.ts b/lib/chat/excel-protocol.ts new file mode 100644 index 0000000..bb4e9d9 --- /dev/null +++ b/lib/chat/excel-protocol.ts @@ -0,0 +1,52 @@ +import type { SpreadsheetAssistantContext } from "@/lib/chat/context"; + +export type ExcelChatHistoryMessage = { + role: "user" | "assistant"; + content: string; +}; + +export type ExcelToolCall = { + toolCallId: string; + toolName: string; + args: unknown; +}; + +export type ExcelToolResult = { + toolCallId: string; + toolName: string; + result: unknown; + isError?: boolean; +}; + +export type ExcelToolRound = { + toolCalls: ExcelToolCall[]; + toolResults: ExcelToolResult[]; +}; + +export type ExcelChatStepRequest = { + threadId: string; + messages: ExcelChatHistoryMessage[]; + model?: string; + provider?: "openai" | "anthropic"; + reasoningEnabled?: boolean; + systemInstructions?: string; + context?: SpreadsheetAssistantContext; + toolRounds?: ExcelToolRound[]; +}; + +export type ExcelChatStepResponse = + | { + ok: true; + type: "assistant"; + message: string; + } + | { + ok: true; + type: "tool_calls"; + toolCalls: ExcelToolCall[]; + } + | { + ok: false; + error: string; + }; + diff --git a/lib/chat/tools.ts b/lib/chat/tools.ts index 3274b0e..bcfb3c1 100644 --- a/lib/chat/tools.ts +++ b/lib/chat/tools.ts @@ -171,6 +171,47 @@ const parseCells = (input: unknown): CellData[][] => { return result; }; +const serializeToolCellValue = ( + cellData: Record | null | undefined, + sharedStrings: Map, +): unknown | undefined => { + if (!cellData) { + return undefined; + } + + const effectiveValue = getCellEffectiveValue(cellData); + const ss = (cellData as { ss?: unknown }).ss; + const ev = + getExtendedValueBool(effectiveValue) ?? + getExtendedValueNumber(effectiveValue) ?? + getExtendedValueString(effectiveValue); + const fv = isNil(ss) ? getCellFormattedValue(cellData) : sharedStrings.get(ss); + const ue = getCellUserEnteredValue(cellData); + const formula = getExtendedValueFormula(ue); + const hasEv = !isNil(ev); + const hasFv = !isNil(fv); + + if (formula) { + const formattedValue = hasFv ? fv : hasEv ? ev : formula; + const effectiveCellValue = hasEv ? ev : hasFv ? fv : formula; + return [formattedValue, effectiveCellValue, formula]; + } + + if (hasFv && hasEv && fv !== ev && fv !== String(ev)) { + return [fv, ev]; + } + + if (hasEv) { + return ev; + } + + if (hasFv) { + return fv; + } + + return undefined; +}; + const documentWriteQueue = new Map>(); const withDocumentWriteLock = async ( @@ -1441,35 +1482,12 @@ const handleSpreadsheetQueryRange = async ( const rowData = sheetData?.[rowIndex]; const cellData = rowData?.values?.[columnIndex]; - if (!cellData) { - cells[address] = null; - continue; - } - - // Cell data may have: value (v), formula (f), formattedValue, style (s), etc. - const effectiveValue = getCellEffectiveValue(cellData); - const ss = cellData.ss; - const ev = - getExtendedValueBool(effectiveValue) ?? - getExtendedValueNumber(effectiveValue) ?? - getExtendedValueString(effectiveValue); - const fv = isNil(ss) - ? getCellFormattedValue(cellData) - : sharedStrings.get(ss); - const ue = getCellUserEnteredValue(cellData); - const formula = getExtendedValueFormula(ue); - - // Determine output format based on what data is available - // Determine output format based on what data is available - if (formula) { - // Formula cell: [formatted, effective, formula] - cells[address] = [fv ?? ev ?? null, ev ?? null, formula]; - } else if (fv !== undefined && fv !== ev && fv !== String(ev)) { - // Formatted differs from effective: [formatted, effective] - cells[address] = [fv, ev ?? null]; - } else { - // Plain value - cells[address] = ev ?? fv ?? null; + const serializedCellValue = serializeToolCellValue( + cellData, + sharedStrings, + ); + if (serializedCellValue !== undefined) { + cells[address] = serializedCellValue; } } } @@ -1506,13 +1524,7 @@ const handleSpreadsheetQueryRange = async ( : ef; console.log("ef", ef, cellXfs, style, cellData); - if (!cellData) { - styles[address] = null; - continue; - } - - if (!style) { - styles[address] = null; + if (!cellData || !style) { continue; } @@ -1891,28 +1903,12 @@ const handleSpreadsheetReadDocument = async ( continue; } - const effectiveValue = getCellEffectiveValue(cellData); - const ss = cellData.ss; - const ev = - getExtendedValueBool(effectiveValue) ?? - getExtendedValueNumber(effectiveValue) ?? - getExtendedValueString(effectiveValue); - const fv = isNil(ss) - ? getCellFormattedValue(cellData) - : sharedStrings.get(ss); - const ue = getCellUserEnteredValue(cellData); - const formula = getExtendedValueFormula(ue); - - // Determine output format based on what data is available - if (formula) { - // Formula cell: [formatted, effective, formula] - cells[address] = [fv ?? ev ?? null, ev ?? null, formula]; - } else if (fv !== undefined && fv !== ev && fv !== String(ev)) { - // Formatted differs from effective: [formatted, effective] - cells[address] = [fv, ev ?? null]; - } else { - // Plain value - cells[address] = ev ?? fv ?? null; + const serializedCellValue = serializeToolCellValue( + cellData, + sharedStrings, + ); + if (serializedCellValue !== undefined) { + cells[address] = serializedCellValue; } } } diff --git a/package.json b/package.json index 9acb57b..b411472 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/office-js": "^1.0.584", "@types/react": "^19", "@types/react-dom": "^19", "@types/sharedb": "^5.1.0", diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 0000000..563aecd --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,34 @@ +import type { AppProps } from "next/app"; +import { + IBM_Plex_Mono, + Plus_Jakarta_Sans, + Sora, +} from "next/font/google"; + +import "@/app/globals.css"; + +const fontBody = Plus_Jakarta_Sans({ + variable: "--font-jakarta", + subsets: ["latin"], +}); + +const fontDisplay = Sora({ + variable: "--font-sora", + subsets: ["latin"], +}); + +const fontMono = IBM_Plex_Mono({ + variable: "--font-plex-mono", + subsets: ["latin"], + weight: "500", +}); + +export default function App({ Component, pageProps }: AppProps) { + return ( +
+ +
+ ); +} diff --git a/pages/_document.tsx b/pages/_document.tsx new file mode 100644 index 0000000..40108fd --- /dev/null +++ b/pages/_document.tsx @@ -0,0 +1,16 @@ +/* eslint-disable @next/next/no-sync-scripts */ +import { Head, Html, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + + +