From 7da88f884565815284de048511ac0fb2bb10bc15 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Tue, 15 Apr 2025 12:44:42 +0200 Subject: [PATCH 01/22] stub: base chat interface --- .env | 2 + apps/backend/package.json | 5 +- apps/backend/src/lib/ai/business-research.ts | 63 + .../src/lib/ai/mark-filing-recommendation.ts | 250 ++++ apps/backend/src/lib/ai/models.ts | 13 + .../src/lib/ai/nice-classification.data.ts | 213 +++ .../backend/src/lib/ai/nice-classification.ts | 80 ++ .../src/lib/ai/relevant-goods-services.ts | 158 +++ apps/backend/src/routes/api/chat.ts | 70 + apps/backend/src/routes/api/index.ts | 9 +- apps/www/package.json | 1 + apps/www/src/lib/backend.ts | 3 +- apps/www/src/routes/index.tsx | 375 ++++-- packages/env/src/env.ts | 1 + packages/ui/package.json | 9 +- .../ui/src/components/chat/chat-input.tsx | 198 +++ .../src/components/chat/chat-message-area.tsx | 73 + .../ui/src/components/chat/chat-message.tsx | 255 ++++ .../ui/src/components/chat/file-preview.tsx | 153 +++ .../src/components/chat/markdown-content.tsx | 330 +++++ .../src/components/chat/typing-indicator.tsx | 15 + packages/ui/src/components/icon.tsx | 28 +- packages/ui/src/hooks/use-scroll-to-bottom.ts | 124 ++ packages/ui/src/hooks/use-textarea-resize.ts | 37 + packages/ui/src/styles.css | 10 + pnpm-lock.yaml | 1184 ++++++++++++++++- 26 files changed, 3503 insertions(+), 156 deletions(-) create mode 100644 apps/backend/src/lib/ai/business-research.ts create mode 100644 apps/backend/src/lib/ai/mark-filing-recommendation.ts create mode 100644 apps/backend/src/lib/ai/models.ts create mode 100644 apps/backend/src/lib/ai/nice-classification.data.ts create mode 100644 apps/backend/src/lib/ai/nice-classification.ts create mode 100644 apps/backend/src/lib/ai/relevant-goods-services.ts create mode 100644 apps/backend/src/routes/api/chat.ts create mode 100644 packages/ui/src/components/chat/chat-input.tsx create mode 100644 packages/ui/src/components/chat/chat-message-area.tsx create mode 100644 packages/ui/src/components/chat/chat-message.tsx create mode 100644 packages/ui/src/components/chat/file-preview.tsx create mode 100644 packages/ui/src/components/chat/markdown-content.tsx create mode 100644 packages/ui/src/components/chat/typing-indicator.tsx create mode 100644 packages/ui/src/hooks/use-scroll-to-bottom.ts create mode 100644 packages/ui/src/hooks/use-textarea-resize.ts diff --git a/.env b/.env index 6b4f7bf2a..3c0d2b9f2 100644 --- a/.env +++ b/.env @@ -8,6 +8,8 @@ DOTENV_PUBLIC_KEY="029b5432287e802a315a922dd9d54d39c60a9c2b90352e6e182c7ebd76085 # .env VITE_APP_URL="encrypted:BDbMPH5ripMKzc8yS34biIuKlfieTkp6tsyU1HQxJVVAq3oY07uPy3KJVS/BSy4neK2fXD7KCuBiZZiEYPlCFfbvO9XdKAzTC7LjAlkemCQm6FgnqdYTjXbS9DdzKGb4Krud8FDJNS0fxa8vNvW9JGqaukpKoxo=" DATABASE_URL="postgresql://postgres:postgres@localhost:9876/app" +GOOGLE_GENERATIVE_AI_API_KEY="encrypted:BOpnkCvtUHsElQPq/bd0nWJfao3ZbDtU8/ryBS8iW0Bz7GiTjIqQ+4JZJNCwE2PhIa/G3NfKpAOYLhHcbK9zgQCU60JH8SurgdKMyfYJOHtNqg7MCCnE/BD2zd19hE+iYpl0q5N9S/f/wGYyey/zIxA6K5sQ0TjCN+SxQCHNXR4Pj5j05jrUlA==" + CLOUDFLARE_API_TOKEN="encrypted:BCdyokIzh8j7Q2028lZ8ekpL0XQUEuYRDU907vzPeAb0Dfir9APnXX1f9ZszJHDtxBFX1tB2bbB97EPHGx53oHVDUuNHZ5voxPEoUd4j6TTeTYFiOQppqEpUzMgh8LdUWFdDJE48aF3D8YbC41NJTmOZ8/Yr14KcniRqCtdp6IApvYSlOk3w2f8=" CLOUDFLARE_DEFAULT_ACCOUNT_ID="encrypted:BJ6dZE7SYXyvaGfSgB8gjHkNpogMZDj5VSTXafLLsG57urj6OOh3GFde7YmWYIyMzjMaTBARTE+WzPL/8LrhxEpEx8+Y+J16SV71ZRXu2MhWDpo6Buzt+Ycn6iV4KytPHfQ221dUTHaVnqxLgYjCF3az20geiuyX2ZJjOTFSbpsT" CLOUDFLARE_ZONE_ID="encrypted:BEuw3Ja75yhNplJ/BYWOgQdcjvVRnvJri/k8XSOdAohsZPiR/cchy1vd/1SvUXvDmqqsgBdUyEFsJ2OhRECAIILa70TvgqB8M/Yw/Lnuqr6zr2bUkGrFlYIR95/MYNbeMU+ML5fUYuAWcWq7zsqeyXhwRt6J03buSf4qLVrVJjLR" diff --git a/apps/backend/package.json b/apps/backend/package.json index faa4efba0..988831a10 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,9 +19,12 @@ "with-env-prod": "dotenvx run -f ../../.env -- " }, "dependencies": { - "@hono/node-server": "^1.14.1", + "@ai-sdk/google": "^1.2.11", "@rectangular-labs/db": "workspace:*", "@rectangular-labs/env": "workspace:*", + "@rectangular-labs/result": "workspace:*", + "ai": "^4.3.6", + "arktype": "^2.1.19", "hono": "^4.7.6" }, "devDependencies": { diff --git a/apps/backend/src/lib/ai/business-research.ts b/apps/backend/src/lib/ai/business-research.ts new file mode 100644 index 000000000..86292e3e6 --- /dev/null +++ b/apps/backend/src/lib/ai/business-research.ts @@ -0,0 +1,63 @@ +import { google } from "@ai-sdk/google"; +import { safe } from "@rectangular-labs/result"; +import { jsonSchema, tool } from "ai"; +import { generateText } from "ai"; + +export const backgroundResearch = tool({ + description: + "Conducts background research on a specified business using Google search grounding to get current information.", + // Use jsonSchema helper with ArkType + parameters: jsonSchema<{ + businessName: string; + }>({ + type: "object", + properties: { + businessName: { + type: "string", + }, + }, + required: ["businessName"], + }), + execute: async ({ businessName }) => { + console.log(`Conducting background research for: ${businessName}`); + const result = await safe(() => + generateText({ + model: google("gemini-2.5-pro-exp-03-25", { + // Enable search grounding + useSearchGrounding: true, + }), + prompt: `Provide a concise background summary of the business "${businessName}", focusing on its core activities, market presence, and any notable recent developments relevant to trademark considerations. Use search grounding to ensure information is current.`, + }), + ); + if (!result.ok) { + console.error( + "Error performing background research for ${businessName}:", + result.error, + ); + return `An error occurred while researching ${businessName}. Please try again later or proceed without this specific background information.`; + } + const { text, finishReason, usage, providerMetadata } = result.value; + + console.log( + `Background research for ${businessName} finished. Reason: ${finishReason}, Usage: ${JSON.stringify( + usage, + )}`, + ); + // Log grounding metadata if available (optional) + const googleMeta = providerMetadata?.google; + console.log("googleMeta", googleMeta); + if (googleMeta?.groundingMetadata) { + console.log( + "Grounding Metadata:", + googleMeta.groundingMetadata.groundingChunks, + ); + console.log( + "Grounding Metadata:", + googleMeta.groundingMetadata.groundingSupports, + ); + } + + // Return the research summary text + return { businessBackgroundInfo: text }; + }, +}); diff --git a/apps/backend/src/lib/ai/mark-filing-recommendation.ts b/apps/backend/src/lib/ai/mark-filing-recommendation.ts new file mode 100644 index 000000000..3276ea59b --- /dev/null +++ b/apps/backend/src/lib/ai/mark-filing-recommendation.ts @@ -0,0 +1,250 @@ +import { tool, generateObject, jsonSchema } from "ai"; +import { type } from "arktype"; +import { mainAgentModel } from "./models"; + +// --- Input Schema --- + +// Optional info about an image mark provided by the user +const markImageInfoSchema = type({ + mimeType: "string", // e.g., "image/jpeg", "image/png" +}); + +// Parameters expected by the tool's execute function +const paramsSchema = type({ + backgroundInfo: "string", // Business/product background provided by the user or context + markText: "string?", // The text mark provided by the user (if any) + markImageInfo: `${markImageInfoSchema.expression}?`, // Info about the image mark (if any) + // Optional: User-provided context on services/classes, if available + userProvidedServices: "string[]?", + userProvidedClasses: "number[]?", +}).assert( + // Ensure that either a text mark or an image mark is available for analysis + (data) => data.markText !== undefined || data.markImageInfo !== undefined, + "Tool requires either markText or markImageInfo.", +); + +// --- Output Schema --- + +// Structure for recommended NICE classification +const niceClassSchema = type({ + classNumber: "number", // The NICE class number (e.g., 9, 42) + reasoning: "string", // Justification for why this class is relevant + examples: "string[]", // Example goods/services under this class relevant to the user +}); + +// Structure for potential objections identified +const potentialObjectionSchema = type({ + reason: "string", // High-level reason (e.g., "Descriptive Mark", "Similarity to Existing Mark") + details: "string", // Specific explanation related to the user's mark + relevantActSection: "string?", // Corresponding section of the Trade Marks Act (e.g., "7(1)(c)") +}); + +// The final structured output of the tool's analysis +const outputSchema = type({ + // If critical information is missing (mark, services), provide a question for the user + requiresUserInput: "string?", + // Recommendation on how to file the mark + recommendedMarkType: + "'Word Mark' | 'Logo Mark' | 'Composite Mark' | 'Undetermined'?", + // Overall summary of the mark's registrability and key points + assessmentSummary: "string", + // List of suggested NICE classes + recommendedNiceClasses: [niceClassSchema, "[]"], + // List of potential grounds for refusal by IPOS + potentialObjections: [potentialObjectionSchema, "[]"], + // Suggested next actions for the user + nextSteps: ["string", "[]"], +}); + +// --- Tool Definition --- + +export const markFilingRecommendation = tool({ + description: `Analyzes a proposed trademark (text or image) based on user-provided background information. +Provides an initial assessment for Singapore trademark registration, considering: +- Optimal mark type (Word, Logo, Composite). +- Potential objections under the Singapore Trade Marks Act 1998 and IPOS guidelines (distinctiveness, descriptiveness, similarity, etc.). +- Recommended NICE classification classes. +- Actionable next steps. +Requires either a text description of the mark or information that an image mark was provided.`, + parameters: jsonSchema( + paramsSchema.toJsonSchema(), + ), + execute: async ({ + backgroundInfo, + markText, + markImageInfo, + userProvidedServices, + userProvidedClasses, + }) => { + console.log("Executing markFilingRecommendation tool..."); + console.log("Received params:", { + backgroundInfo, + markText, + markImageInfo, + userProvidedServices, + userProvidedClasses, + }); + + // Construct the part of the prompt describing the mark provided + let markDescription = ""; + if (markText && markImageInfo) { + markDescription = `The user provided both a text mark ("${markText}") and a logo mark (image type: ${markImageInfo.mimeType}). Analyze them together as potentially a composite mark, but also evaluate the word mark on its own.`; + } else if (markText) { + markDescription = `The user provided the following text mark: "${markText}"`; + } else if (markImageInfo) { + markDescription = `The user provided a logo mark (image type: ${markImageInfo.mimeType}). Please analyze the visual elements conveyed in the image.`; + } else { + // This case should be prevented by the .assert in paramsSchema, but handle defensively + console.error("Tool called without markText or markImageInfo."); + return { + requiresUserInput: + "Error: Tool requires mark information. Please provide the mark text or image.", + assessmentSummary: "", + recommendedNiceClasses: [], + potentialObjections: [], + nextSteps: [], + }; + } + + // Include user-provided services/classes in the prompt if available + let serviceClassContext = ""; + if (userProvidedServices && userProvidedServices.length > 0) { + serviceClassContext += `The user mentioned the following goods/services: ${userProvidedServices.join(", ")}.\n`; + } + if (userProvidedClasses && userProvidedClasses.length > 0) { + serviceClassContext += `The user mentioned the following NICE classes: ${userProvidedClasses.join(", ")}.\n`; + } + if (!serviceClassContext) { + serviceClassContext = + "The user did not explicitly state their goods/services or NICE classes. If the background info is insufficient, please research the business based on the background to propose relevant classes. If research is not possible, state that goods/services information is required.\n"; + } + + // --- Start New Prompt --- + const newPrompt = ` +Role Definition + +You are the world's best Singapore-qualified lawyer, trained to provide accurate and clear legal analysis on the client's submitted signs (words and logos) to assess whether they qualify as a registrable trademark with the Intellectual Property Office of Singapore (IPOS). You are heavily incentivize to succeed. +Your role is to evaluate the proposed trademark and provide a short initial assessment based on whether the client's sign should be filed as a logo mark, word mark, or composite mark, against the: +Singapore Trade Marks Act 1998 https://sso.agc.gov.sg/Act/TMA1998?WholeDoc=1 +IPOS Trade Marks Work Manual https://www.ipos.gov.sg/about-ip/trade-marks/managing-trade-marks/guides +Relevant Singapore trademark case law + +Client Input: +Business Background: +--- Start Background --- +${backgroundInfo} +--- End Background --- + +Proposed Mark: +${markDescription} + +Goods/Services Context: +${serviceClassContext} + +Objectives: + +The objective of your analysis is to answer two questions (which are reflected in the output schema fields: 'recommendedMarkType' and 'potentialObjections'): +1. How should the client register their mark? Logo Mark, Word Mark, or Composite Mark? +2. What are the possible grounds for objection by IPOS for the mark? + +Note that before evaluating, you will, as the example output provides, be required to do a search on the business and give an evaluation of what classes they are to file under (reflected in 'recommendedNiceClasses'). Conduct the following analysis with the basis that you are filing under the said classes. + +You are to read and analyse all resources thoroughly before providing advice, and base your advice on the sources provided. + +When evaluating these two questions based on the client's submitted signs, consider the following regarding the signs: +1. Does it satisfy the definition of a trade mark in section 2(1) of the Trade Marks Act? + '"'trade mark" means any sign capable of being represented graphically and which is capable of distinguishing goods or services dealt with or provided in the course of trade by a person from goods or services so dealt with or provided by any other person; +2. Is it a non-distinctive mark (Section 7(1)(b))? + - Trade marks which are devoid of any distinctive character. + - Resources: IPOS Guideline, Love & Co Pte Ltd v The Carat Club Pte Ltd (2008), The Polo/Lauren Co, LP v Shop In Department Store Pte Ltd [2005] SGCA 21. + - Flag common words/generic branding unless distinctiveness acquired through use. +3. Is it a Descriptive Mark (Section 7(1)(c))? + - Trade marks which consist exclusively of signs or indications which may serve, in trade, to designate the kind, quality, quantity, intended purpose, value, geographical origin, time of production, or other characteristics. + - Resources: IPOS Guideline, Rovio Entertainment Ltd v Kimanis Food Industries Sdn Bhd [2015] SGHC 216. + - Assess if the mark merely informs or contains arbitrary elements. +4. Does it consist of Customary Trade Terms (Section 7(1)(d))? + - Trade marks which consist exclusively of signs or indications which have become customary in the current language or trade practices. + - Resources: Viet Huong Trading Co Ltd v Tan Wing Hong [2009] SGHC 150. + - Check industry usage; generic terms may not be registrable alone. +5. Is it contrary to public policy or morality (Section 7(4)(a))? + - Resources: IPOS Guideline, Application by Heineken Asia Pacific Pte Ltd [2016] SGIPOS 7 ("Tiger Balls" example). + - Flag potential vulgarity, religious sensitivity, or offensive references. +6. Is it of such a nature as to deceive the public (Section 7(4)(b))? + - E.g., regarding nature, quality, or geographical origin. + - Resources: IPOS Guideline, Consorzio del Prosciutto di Parma v Aslan Trading Singapore Pte Ltd [2019] SGIPOS 10 ("Parma" example). + - Flag misleading terms (esp. geographical) and recommend disclaimers if needed. +7. Does it contain/consist of a geographical indication for wine/spirit not originating from that place (Section 7(7))? + - Resources: IPOS Guideline, Consorzio per la Tutela del Formaggio Gorgonzola v Fonterra Brands (Singapore) Pte Ltd [2005] SGIPOS 2 ("Gorgonzola" example). + - Identify geographical terms and check protected GI databases. +8. Is it a slogan? + - Resources: IPOS Guideline, McDonald's Corp v Future Enterprises Pte Ltd [2005] SGCA 50 ("I'm lovin' it" example). + - Flag short phrases as potentially weak unless distinctiveness acquired. +9. Is it identical with an earlier trade mark for identical goods/services (Section 8(1))? + - Resource (use for 9, 10, 11): IPOS Guideline, Ferrero S.p.A. v Sarika Connoisseur Cafe Pte Ltd [2011] SGHC 176 ("Nutello" vs "Nutella"). + - Assess likelihood of confusion even with slight variations. Requires comparison against IPOS database (NOTE: You cannot access external websites like the IPOS database directly. State this limitation and recommend a formal search as a next step). +10. Is it identical with an earlier trade mark for similar goods/services (Section 8(2)(a))? + - (See point 9 resources and limitation). +11. Is it similar to an earlier trade mark which is well known in Singapore (Section 8(2)(b))? + - Resources: Novelty Pte Ltd v Amanresorts Ltd [2009] 3 SLR 216, Mobil Petroleum Company, Inc v Hyundai Mobis [2009] SGCA 38. + - Famous brands get broader protection. Check for global recognition. (NOTE: State limitations in assessing "well-known" status without comprehensive market data). + +Key Terms Glossary +- Logo: A visual design (symbol, icon, or image). +- Wordmark: Text-only trademark. +- Composite Mark: Combines logo and wordmark. Recommended if word elements are weak/descriptive or other issues exist. + +Output Instructions: +You are required to GENERATE A JSON OBJECT matching the defined output schema. +1. Review the submitted sign(s) (text/logo) based on the background and legal criteria. +2. Determine Recommended Mark Type: 'Word Mark' (if distinctive words), 'Logo Mark' (if distinctiveness is visual), 'Composite Mark' (if words are weak/descriptive, or combined elements needed). Use 'Undetermined' if analysis is inconclusive without more info. +3. Recommend NICE Classes: Based on background/services (provided or researched). Propose class numbers, reasoning, and examples using IPOS/NICE guidelines. If research fails, indicate need for user input via 'requiresUserInput'. +4. Identify Potential Objections: List grounds for refusal (non-distinctive, descriptive, customary, deceptive, morality, similarity). Provide specific details and Act sections where possible. State limitations regarding direct database checks and recommend formal searches. +5. Provide Assessment Summary: A brief, client-specific analysis synthesizing the findings. +6. Suggest Next Steps: Concrete actions like "Conduct a formal clearance search via the firm", "Refine the mark to enhance distinctiveness", "Consult with the firm to finalize filing strategy". +7. Handle Missing Information: + - If no mark is identifiable (error case, should not happen with schema validation), use 'requiresUserInput'. + - If goods/services are unclear and cannot be inferred, use 'requiresUserInput' to ask for them. + - If no significant issues are found, provide a positive assessment summary and recommend proceeding with filing as a next step. + +Evaluation Rules (MUST follow these in EVERY assessment) +- MANDATORY: Provide direct client-specific analysis, justifying conclusions. +- MANDATORY: Always recommend the best mark type (Word, Logo, Composite, or Undetermined). +- MANDATORY: Address potential similarity issues, state database check limitations, and recommend a formal search. +- MANDATORY: Recommend NICE Classification Classes (research if needed, state if more info required). +- MANDATORY: Output MUST follow the JSON schema strictly. Do not add commentary outside the JSON structure. +`; + + try { + const { object } = await generateObject({ + model: mainAgentModel, // Use a powerful model for legal reasoning + schema: jsonSchema( + outputSchema.toJsonSchema(), + ), + prompt: newPrompt, + mode: "json", + }); + console.log("markFilingRecommendation tool result:", object); + // Validate or sanitize the object if necessary before returning + // For now, assume the model respects the schema + return object; + } catch (error) { + console.error( + "Error executing markFilingRecommendation tool with generateObject:", + error, + ); + // Return a structured error within the expected schema + return { + assessmentSummary: + "An error occurred while analyzing the trademark information.", + potentialObjections: [ + { + reason: "Tool Execution Error", + details: error instanceof Error ? error.message : String(error), + }, + ], + recommendedNiceClasses: [], + nextSteps: ["Please try again or report this issue."], + }; + } + }, +}); diff --git a/apps/backend/src/lib/ai/models.ts b/apps/backend/src/lib/ai/models.ts new file mode 100644 index 000000000..64b3f9790 --- /dev/null +++ b/apps/backend/src/lib/ai/models.ts @@ -0,0 +1,13 @@ +import { google } from "@ai-sdk/google"; + +// Define specific models. The SDK automatically uses the environment variable +// GOOGLE_GENERATIVE_AI_API_KEY if provided. + +// Model for the main chat agent with multimodal and grounding capabilities +export const mainAgentModel = google("gemini-2.5-pro-exp-03-25"); + +// Model for NICE classification (structured output, potentially flash for cost/speed) +export const niceClassificationModel = google("gemini-2.5-pro-exp-03-25"); + +// Model for Goods & Services recommendations (structured output) +export const goodsServicesModel = google("gemini-2.5-pro-exp-03-25"); diff --git a/apps/backend/src/lib/ai/nice-classification.data.ts b/apps/backend/src/lib/ai/nice-classification.data.ts new file mode 100644 index 000000000..19b5c70cf --- /dev/null +++ b/apps/backend/src/lib/ai/nice-classification.data.ts @@ -0,0 +1,213 @@ +export const niceClassificationData = [ + { + class: 1, + description: + "Chemicals for use in industry, science and photography, as well as in agriculture, horticulture and forestry; unprocessed artificial resins, unprocessed plastics; fire extinguishing and fire prevention compositions; tempering and soldering preparations; substances for tanning animal skins and hides; adhesives for use in industry; putties and other paste fillers; compost, manures, fertilizers; biological preparations for use in industry and science", + }, + { + class: 2, + description: + "Paints, varnishes, lacquers; preservatives against rust and against deterioration of wood; colorants, dyes; inks for printing, marking and engraving; raw natural resins; metals in foil and powder form for use in painting, decorating, printing and art", + }, + { + class: 3, + description: + "Non-medicated cosmetics and toiletry preparations; non-medicated dentifrices; perfumery, essential oils; bleaching preparations and other substances for laundry use; cleaning, polishing, scouring and abrasive preparations", + }, + { + class: 4, + description: + "Industrial oils and greases, wax; lubricants; dust absorbing, wetting and binding compositions; fuels and illuminants; candles and wicks for lighting", + }, + { + class: 5, + description: + "Pharmaceuticals, medical and veterinary preparations; sanitary preparations for medical purposes; dietetic food and substances adapted for medical or veterinary use, food for babies; dietary supplements for human beings and animals; plasters, materials for dressings; material for stopping teeth, dental wax; disinfectants; preparations for destroying vermin; fungicides, herbicides", + }, + { + class: 6, + description: + "Common metals and their alloys, ores; metal materials for building and construction; transportable buildings of metal; non-electric cables and wires of common metal; small items of metal hardware; metal containers for storage or transport; safes", + }, + { + class: 7, + description: + "Machines, machine tools, power-operated tools; motors and engines, except for land vehicles; machine coupling and transmission components, except for land vehicles; agricultural implements, other than hand-operated hand tools; incubators for eggs; automatic vending machines", + }, + { + class: 8, + description: + "Hand tools and implements, hand-operated; cutlery; side arms, except firearms; razors", + }, + { + class: 9, + description: + "Scientific, research, navigation, surveying, photographic, cinematographic, audiovisual, optical, weighing, measuring, signalling, detecting, testing, inspecting, life-saving and teaching apparatus and instruments; apparatus and instruments for conducting, switching, transforming, accumulating, regulating or controlling the distribution or use of electricity; apparatus and instruments for recording, transmitting, reproducing or processing sound, images or data; recorded and downloadable media, computer software, blank digital or analogue recording and storage media; mechanisms for coin-operated apparatus; cash registers, calculating devices; computers and computer peripheral devices; diving suits, divers' masks, ear plugs for divers, nose clips for divers and swimmers, gloves for divers, breathing apparatus for underwater swimming; fire-extinguishing apparatus", + }, + { + class: 10, + description: + "Surgical, medical, dental and veterinary apparatus and instruments; artificial limbs, eyes and teeth; orthopaedic articles; suture materials; therapeutic and assistive devices adapted for persons with disabilities; massage apparatus; apparatus, devices and articles for nursing infants; sexual activity apparatus, devices and articles", + }, + { + class: 11, + description: + "Apparatus and installations for lighting, heating, cooling, steam generating, cooking, drying, ventilating, water supply and sanitary purposes", + }, + { + class: 12, + description: "Vehicles; apparatus for locomotion by land, air or water", + }, + { + class: 13, + description: "Firearms; ammunition and projectiles; explosives; fireworks", + }, + { + class: 14, + description: + "Precious metals and their alloys; jewellery, precious and semi-precious stones; horological and chronometric instruments", + }, + { + class: 15, + description: + "Musical instruments; music stands and stands for musical instruments; conductors' batons", + }, + { + class: 16, + description: + "Paper and cardboard; printed matter; bookbinding material; photographs; stationery and office requisites, except furniture; adhesives for stationery or household purposes; drawing materials and materials for artists; paintbrushes; instructional and teaching materials; plastic sheets, films and bags for wrapping and packaging; printers' type, printing blocks", + }, + { + class: 17, + description: + "Unprocessed and semi-processed rubber, gutta-per cha, gum, asbestos, mica and substitutes for all these materials; plastics and resins in extruded form for use in manufacture; packing, stopping and insulating materials; flexible pipes, tubes and hoses, not of metal", + }, + { + class: 18, + description: + "Leather and imitations of leather; animal skins and hides; luggage and carrying bags; umbrellas and parasols; walking sticks; whips, harness and saddlery; collars, leashes and clothing for animals", + }, + { + class: 19, + description: + "Materials, not of metal, for building and construction; rigid pipes, not of metal, for building; asphalt, pitch, tar and bitumen; transportable buildings, not of metal; monuments, not of metal", + }, + { + class: 20, + description: + "Furniture, mirrors, picture frames; containers, not of metal, for storage or transport; unworked or semi-worked bone, horn, whalebone or mother-of-pearl; shells; meerschaum; yellow amber", + }, + { + class: 21, + description: + "Household or kitchen utensils and containers; cookware and tableware, except forks, knives and spoons; combs and sponges; brushes, except paintbrushes; brush-making materials; articles for cleaning purposes; unworked or semi-worked glass, except building glass; glassware, porcelain and earthenware", + }, + { + class: 22, + description: + "Ropes and string; nets; tents and tarpaulins; awnings of textile or synthetic materials; sails; sacks for the transport and storage of materials in bulk; padding, cushioning and stuffing materials, except of paper, cardboard, rubber or plastics; raw fibrous textile materials and substitutes therefor", + }, + { class: 23, description: "Yarns and threads for textile use" }, + { + class: 24, + description: + "Textiles and substitutes for textiles; household linen; curtains of textile or plastic", + }, + { class: 25, description: "Clothing, footwear, headwear" }, + { + class: 26, + description: + "Lace, braid and embroidery, and haberdashery ribbons and bows; buttons, hooks and eyes, pins and needles; artificial flowers; hair decorations; false hair", + }, + { + class: 27, + description: + "Carpets, rugs, mats and matting, linoleum and other materials for covering existing floors; wall hangings, not of textile", + }, + { + class: 28, + description: + "Games, toys and playthings; video game apparatus; gymnastic and sporting articles; decorations for Christmas trees", + }, + { + class: 29, + description: + "Meat, fish, poultry and game; meat extracts; preserved, frozen, dried and cooked fruits and vegetables; jellies, jams, compotes; eggs; milk, cheese, butter, yogurt and other milk products; oils and fats for food", + }, + { + class: 30, + description: + "Coffee, tea, cocoa and artificial coffee; rice, pasta and noodles; tapioca and sago; flour and preparations made from cereals; bread, pastries and confectionery; chocolate; ice cream, sorbets and other edible ices; sugar, honey, treacle; yeast, baking-powder; salt, seasonings, spices, preserved herbs; vinegar, sauces and other condiments; ice (frozen water)", + }, + { + class: 31, + description: + "Raw and unprocessed agricultural, aquacultural, horticultural and forestry products; raw and unprocessed grains and seeds; fresh fruits and vegetables, fresh herbs; natural plants and flowers; bulbs, seedlings and seeds for planting; live animals; foodstuffs and beverages for animals; malt", + }, + { + class: 32, + description: + "Beers; non-alcoholic beverages; mineral and aerated waters; fruit beverages and fruit juices; syrups and other non-alcoholic preparations for making beverages", + }, + { + class: 33, + description: + "Alcoholic beverages, except beers; alcoholic preparations for making beverages", + }, + { + class: 34, + description: + "Tobacco and tobacco substitutes; cigarettes and cigars; electronic cigarettes and oral vaporizers for smokers; smokers' articles; matches", + }, + { + class: 35, + description: + "Advertising; business management; business administration; office functions", + }, + { + class: 36, + description: + "Insurance; financial affairs; monetary affairs; real estate affairs", + }, + { + class: 37, + description: + "Construction services; installation and repair services; mining extraction, oil and gas drilling", + }, + { class: 38, description: "Telecommunications services" }, + { + class: 39, + description: + "Transport; packaging and storage of goods; travel arrangement", + }, + { + class: 40, + description: + "Treatment of materials; recycling of waste and trash; air purification and treatment of water; printing services; food and drink preservation", + }, + { + class: 41, + description: + "Education; providing of training; entertainment; sporting and cultural activities", + }, + { + class: 42, + description: + "Scientific and technological services and research and design relating thereto; industrial analysis, industrial research and industrial design services; quality control and authentication services; design and development of computer hardware and software", + }, + { + class: 43, + description: + "Services for providing food and drink; temporary accommodation", + }, + { + class: 44, + description: + "Medical services; veterinary services; hygienic and beauty care for human beings or animals; agriculture, aquaculture, horticulture and forestry services", + }, + { + class: 45, + description: + "Legal services; security services for the physical protection of tangible property and individuals; personal and social services rendered by others to meet the needs of individuals", + }, +]; diff --git a/apps/backend/src/lib/ai/nice-classification.ts b/apps/backend/src/lib/ai/nice-classification.ts new file mode 100644 index 000000000..8c5dda7d1 --- /dev/null +++ b/apps/backend/src/lib/ai/nice-classification.ts @@ -0,0 +1,80 @@ +import { safe } from "@rectangular-labs/result"; +import { generateObject, jsonSchema, tool } from "ai"; +import { niceClassificationModel } from "./models"; +import { niceClassificationData } from "./nice-classification.data"; + +// Format the NICE data for inclusion in the prompt +const niceDataPromptSection = niceClassificationData + .map((item) => `Class ${item.class}: ${item.description}`) + .join("\n"); + +export const niceClassification = tool({ + description: + "Classifies a business activity based on background information according to the official NICE classification system. Provides relevant class numbers and reasoning.", + parameters: jsonSchema<{ + backgroundInfo: string; + }>({ + type: "object", + properties: { + backgroundInfo: { + type: "string", + }, + }, + required: ["backgroundInfo"], + }), + execute: async ({ backgroundInfo }) => { + console.log( + `Executing niceClassification tool with background: ${backgroundInfo.substring(0, 100)}...`, + ); + const result = await safe(() => + generateObject({ + model: niceClassificationModel, // Use the dedicated model + schema: jsonSchema<{ + classification: { + class: number; + reasoning: string; + }[]; + }>({ + type: "object", + properties: { + classification: { + type: "array", + items: { + type: "object", + properties: { + class: { type: "number" }, + reasoning: { type: "string" }, + }, + required: ["class", "reasoning"], + }, + }, + }, + required: ["classification"], + }), + prompt: `Analyze the following business background information and determine the most relevant NICE classification(s). + + Background Information: + --- Start Background --- + ${backgroundInfo} + --- End Background --- + + Reference NICE Classification List: + --- Start NICE List --- + ${niceDataPromptSection} + --- End NICE List --- + + Respond ONLY with the JSON object matching the requested schema, providing the class number and your reasoning for each relevant classification based *only* on the provided background information and the NICE list. + If multiple classes seem relevant, include them all. + If no classes seem relevant or the information is insufficient, return an empty array for 'classification'.`, + mode: "json", + }), + ); + if (!result.ok) { + console.error("Error executing niceClassification tool:", result.error); + return { classification: [] }; + } + const { object } = result.value; + console.log("niceClassification tool result:", object); + return object; // Return the structured data + }, +}); diff --git a/apps/backend/src/lib/ai/relevant-goods-services.ts b/apps/backend/src/lib/ai/relevant-goods-services.ts new file mode 100644 index 000000000..3e553fe1c --- /dev/null +++ b/apps/backend/src/lib/ai/relevant-goods-services.ts @@ -0,0 +1,158 @@ +import { safe } from "@rectangular-labs/result"; +import { generateObject, jsonSchema, tool } from "ai"; +import { goodsServicesModel } from "./models"; + +// --- MOCK DATA --- (Replace with actual data source: API, DB, etc.) +const mockPreapprovedGoodsServices = { + // Class 3: Cosmetics, cleaning + 3: [ + "Bleaching preparations for laundry use", + "Cleaning preparations", + "Polishing preparations", + "Abrasive preparations", + "Soaps", + "Perfumery", + "Essential oils", + "Non-medicated cosmetics", + "Non-medicated hair lotions", + "Non-medicated dentifrices", + ], + // Class 9: Scientific, tech, software + 9: [ + "Computers", + "Computer software, recorded", + "Computer peripheral devices", + "Downloadable computer software applications", + "Data processing apparatus", + "Measuring apparatus", + "Signalling apparatus", + "Optical apparatus and instruments", + "Audiovisual apparatus", + ], + // Class 35: Advertising, business + 35: [ + "Advertising services", + "Business management assistance", + "Business administration services", + "Providing office functions", + "Online advertising on a computer network", + "Sales promotion for others", + "Marketing services", + "Data search in computer files for others", + ], + // Add more mock classes/services as needed +}; +// --- END MOCK DATA --- + +export const relevantGoodsServices = tool({ + description: + "Suggests relevant goods and services from the Singapore preapproved list based on provided NICE classifications and business background.", + parameters: jsonSchema<{ + classifications: { + class: number; + reasoning: string; + }[]; + backgroundInfo: string; + }>({ + type: "object", + properties: { + classifications: { + type: "array", + items: { + type: "object", + properties: { + class: { type: "number" }, + reasoning: { type: "string" }, + }, + required: ["class", "reasoning"], + }, + }, + backgroundInfo: { type: "string" }, + }, + required: ["classifications", "backgroundInfo"], + }), + execute: async ({ classifications, backgroundInfo }, { messages }) => { + console.log("Executing relevantGoodsServices tool..."); + console.log("Classifications:", classifications); + console.log("messages", messages); + + if (!classifications || classifications.length === 0) { + console.log("No classifications provided."); + return { relevantServices: [] }; + } + + // Prepare the relevant mock data based on input classes + let relevantMockDataPromptSection = + "Relevant Preapproved Goods/Services (Examples):\n"; + let foundData = false; + for (const { class: classNum } of classifications) { + const services = + mockPreapprovedGoodsServices[ + classNum as keyof typeof mockPreapprovedGoodsServices + ]; + if (services) { + relevantMockDataPromptSection += `Class ${classNum}:\n - ${services.join("\n - ")}\n`; + foundData = true; + } + } + + if (!foundData) { + relevantMockDataPromptSection += + "No specific examples found for the provided class(es) in the current list."; + } + + // Add type annotation for map callback parameter + const classificationsText = classifications + .map( + (c: { class: number; reasoning: string }) => + `- Class ${c.class}: ${c.reasoning}`, + ) + .join("\n"); + + const result = await safe(() => + generateObject({ + model: goodsServicesModel, + schema: jsonSchema<{ + relevantServices: string[]; + }>({ + type: "object", + properties: { + relevantServices: { type: "array", items: { type: "string" } }, + }, + required: ["relevantServices"], + }), + prompt: `Based on the following business background information, the suggested NICE classifications, and the provided examples from the Singapore preapproved list, identify and list the *most relevant* specific goods and services that should be included in a trademark application. + + Business Background: + --- Start Background --- + ${backgroundInfo} + --- End Background --- + + Suggested NICE Classifications: + ${classificationsText} + + ${relevantMockDataPromptSection} + + Instructions: + - Focus on specificity. Select items directly from the provided examples if they fit the background. + - If the examples are insufficient but the background suggests other standard items for the class, you may infer *very common* ones (e.g., 'computer software' for Class 9 if background is software development). + - Do NOT invent niche or overly specific items not commonly found on preapproved lists. + - Aim for a concise list of the *most* applicable items. + - Return ONLY the JSON object matching the schema with the list of strings. + - If no relevant services can be determined, return an empty array. + `, + mode: "json", + }), + ); + if (!result.ok) { + console.error( + "Error executing relevantGoodsServices tool:", + result.error, + ); + return { relevantServices: [] }; + } + const { object } = result.value; + console.log("relevantGoodsServices tool result:", object); + return object; + }, +}); diff --git a/apps/backend/src/routes/api/chat.ts b/apps/backend/src/routes/api/chat.ts new file mode 100644 index 000000000..d98f1443f --- /dev/null +++ b/apps/backend/src/routes/api/chat.ts @@ -0,0 +1,70 @@ +import { backgroundResearch } from "@/lib/ai/business-research"; +import { mainAgentModel } from "@/lib/ai/models"; +import { niceClassification } from "@/lib/ai/nice-classification"; +import { relevantGoodsServices } from "@/lib/ai/relevant-goods-services"; +import { streamText } from "ai"; +import { Hono } from "hono"; +import { stream } from "hono/streaming"; + +// Define the system prompt +const systemPrompt = `You are an expert Singapore trademark law assistant working for a prestigious law firm. +Your primary goal is to understand the user's request regarding trademark registration, ask clarifying questions if necessary, and utilize the provided tools to gather information. +Available tools allow you to perform: +- Background research on the client's business using current information from the web. +- NICE classification lookup. +- Identification of relevant goods/services based on business descriptions. + +Once you have sufficient information (including background context, NICE classification, and relevant goods/services), your final output MUST be a draft email addressed to the client. +This email should: +1. Acknowledge and clearly answer all aspects of the client's original query. +2. Summarize the findings from your research (background, classification, goods/services). +3. Provide preliminary recommendations based on the findings (e.g., potential classes to file under, type of mark considerations). +4. Politely nudge the client towards engaging the firm for formal filing and consultation, highlighting the firm's expertise. +5. Maintain a professional, helpful, and confident tone. + +Do not provide definitive legal advice, but rather informed recommendations based on the gathered data. Always qualify your recommendations appropriately (e.g., "Based on preliminary analysis...", "We would recommend further consultation to confirm..."). +Do not add disclaimers or warnings. +Use markdown for formatting the email draft.`; + +// Define the POST route for chat requests +export const chatRouter = new Hono().post("/", async (c) => { + // const { messages } = c.req.valid("json"); + const messages = await c.req.json(); + console.log("messages", messages); + + // Define and import actual tools + const tools = { + backgroundResearch: backgroundResearch, + niceClassification: niceClassification, + relevantGoodsServices: relevantGoodsServices, + // markFilingRecommendation: markFilingRecommendation, + }; + + try { + const result = streamText({ + model: mainAgentModel, // Use the main agent model + system: systemPrompt, + messages: messages.messages, + tools: tools, + maxSteps: 10, + onFinish: (result) => { + console.log("result", result); + }, + onError: (error) => { + console.error("Error calling streamText:", error); + }, + }); + + const dataStream = result.toDataStream(); + return stream(c, async (stream) => { + stream.onAbort(() => { + console.log("Stream aborted!"); + }); + await stream.pipe(dataStream); + }); + } catch (error) { + console.error("Error calling streamText:", error); + // Consider returning a more informative error response + return c.json({ error: "Failed to process chat request" }, 500); + } +}); diff --git a/apps/backend/src/routes/api/index.ts b/apps/backend/src/routes/api/index.ts index e46c22183..0d84b0872 100644 --- a/apps/backend/src/routes/api/index.ts +++ b/apps/backend/src/routes/api/index.ts @@ -1,6 +1,9 @@ import type { HonoEnv } from "@/lib/hono"; import { Hono } from "hono"; +import { chatRouter } from "./chat"; -export const apiRouter = new Hono().get("/", (c) => { - return c.json({ message: "Hello, World from the backend!" }); -}); +export const apiRouter = new Hono() + .get("/", (c) => { + return c.json({ message: "Hello, World from the backend!" }); + }) + .route("/chat", chatRouter); diff --git a/apps/www/package.json b/apps/www/package.json index 76992d024..1684a6787 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -15,6 +15,7 @@ "with-env-prod": "dotenvx run -f ../../.env -- " }, "dependencies": { + "@ai-sdk/react": "^1.2.9", "@rectangular-labs/backend": "workspace:*", "@rectangular-labs/env": "workspace:*", "@rectangular-labs/ui": "workspace:*", diff --git a/apps/www/src/lib/backend.ts b/apps/www/src/lib/backend.ts index 6c296fc01..efa2d3056 100644 --- a/apps/www/src/lib/backend.ts +++ b/apps/www/src/lib/backend.ts @@ -1,4 +1,5 @@ import type { AppType } from "@rectangular-labs/backend"; import { hc } from "hono/client"; +import { env } from "./env"; -export const backend = hc("https://localhost:6969"); +export const backend = hc(env.VITE_APP_URL); diff --git a/apps/www/src/routes/index.tsx b/apps/www/src/routes/index.tsx index a22905574..106f21797 100644 --- a/apps/www/src/routes/index.tsx +++ b/apps/www/src/routes/index.tsx @@ -1,140 +1,265 @@ +"use client"; + import { backend } from "@/lib/backend"; -import * as Icons from "@rectangular-labs/ui/components/icon"; +import { type Message, useChat } from "@ai-sdk/react"; +import { + ChatInput, + ChatInputSubmit, + ChatInputTextArea, +} from "@rectangular-labs/ui/components/chat/chat-input"; +import { + ChatMessage, + ChatMessageAvatar, + ChatMessageContent, +} from "@rectangular-labs/ui/components/chat/chat-message"; +import { ChatMessageArea } from "@rectangular-labs/ui/components/chat/chat-message-area"; +import { FilePreview } from "@rectangular-labs/ui/components/chat/file-preview"; +import { TypingIndicator } from "@rectangular-labs/ui/components/chat/typing-indicator"; +import { Paperclip } from "@rectangular-labs/ui/components/icon"; import { ThemeToggle } from "@rectangular-labs/ui/components/theme-provider"; import { Button } from "@rectangular-labs/ui/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@rectangular-labs/ui/components/ui/card"; import { createFileRoute } from "@tanstack/react-router"; +import { useRef, useState } from "react"; + +// Define types for multimodal message content parts +type TextPart = { type: "text"; text: string }; +type FilePart = { + type: "file" | "image"; + data: string; // Base64 + mimeType: string; +}; +type MessageContentPart = TextPart | FilePart; export const Route = createFileRoute("/")({ - component: App, + component: ChatInterface, loader: async () => { - const response = await backend.api.$get(); - return response.json(); + try { + const response = await backend.api.$get(); + return response.json(); + } catch (error) { + console.error("Failed to fetch initial data:", error); + return { message: "Welcome!" }; // Default data if fetch fails + } }, }); -function App() { - const data = Route.useLoaderData(); +// Helper to convert File to Base64 +const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + if (typeof reader.result === "string") { + const parts = reader.result.split(","); + // Ensure the split resulted in at least two parts (prefix and data) + if (parts.length >= 2 && parts[1]) { + resolve(parts[1]); // Return the base64 part + } else { + reject(new Error("Invalid Data URL format")); + } + } else { + reject(new Error("Failed to read file as base64 string")); + } + }; + reader.onerror = reject; + }); + +function ChatInterface() { + // Optional: Use loader data if you fetched something + // const initialData = Route.useLoaderData(); + + const [attachedFiles, setAttachedFiles] = useState([]); + const fileInputRef = useRef(null); + const chatRef = useRef(null); + + const { + messages, + input, + handleInputChange, + handleSubmit, + isLoading, + setMessages, + stop, + } = useChat({ + api: backend.api.chat.$url().href, + onToolCall({ toolCall }) { + console.log("toolCall", toolCall); + }, + }); + + const handleFileChange = (event: React.ChangeEvent) => { + const files = event.target.files; + if (files) { + const newFiles = Array.from(files); + // Basic validation (example: limit count or size) + if (attachedFiles.length + newFiles.length > 5) { + alert("You can attach a maximum of 5 files."); + return; + } + setAttachedFiles((prevFiles) => [...prevFiles, ...newFiles]); + } + // Reset file input value to allow selecting the same file again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const removeFile = (index: number) => { + setAttachedFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); + }; + + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() && attachedFiles.length === 0) return; // Need input or files + + const currentInput = input; // Capture input before clearing + const filesToProcess = attachedFiles; + + // Clear input and files immediately for better UX + const syntheticEvent = { + target: { value: "" }, + } as React.ChangeEvent; // Use correct element type + handleInputChange(syntheticEvent); + setAttachedFiles([]); + + // Prepare multimodal message content with defined type + const userMessageContent: MessageContentPart[] = []; + let userMessageText = ""; // Store text part separately for local update + if (currentInput.trim()) { + const textPart = { type: "text" as const, text: currentInput }; + userMessageContent.push(textPart); + userMessageText = textPart.text; + } + + // Process files + for (const file of filesToProcess) { + try { + const base64Data = await fileToBase64(file); + userMessageContent.push({ + type: file.type.startsWith("image/") ? "image" : "file", + data: base64Data, + mimeType: file.type || "application/octet-stream", // Provide default MIME type + }); + } catch (error) { + console.error("Error processing file:", file.name, error); + // Optionally add an error message to the chat + } + } + + // Create the new user message object + const newUserMessage: Message = { + id: Date.now().toString(), // Temporary ID + role: "user", + // For local state update, useChat expects a string + content: userMessageText, + // We'll send the full structure in the body override + }; + + // Update messages state locally first with just the text + const updatedMessagesForApi = [ + ...messages, + { + id: newUserMessage.id, + role: newUserMessage.role, + content: userMessageContent, // Use full content for API call body + }, + ]; + setMessages([...messages, newUserMessage]); // Update UI with text-only content locally + + // Manually trigger the API call using handleSubmit + // The second argument IS ChatRequestOptions, which includes body + handleSubmit(e, { + body: { + // Send the message list with the *full* content structure + messages: updatedMessagesForApi, + }, + }); + }; + return ( -
- - -
-

{data.message}

- -

- A modern, full-stack development template -

- -
- - - React + TanStack - - Build modern, type-safe UIs - - - -

- A powerful combination for building interactive web applications - with type safety and excellent developer experience. -

-
- - - -
- - - - Hono Backend - Edge-ready backend - - -

- Ultra-fast, lightweight web framework with excellent TypeScript - support and Cloudflare compatibility. -

-
- - - -
- - - - Monorepo Architecture - - Scalable project organization - - - -

- Share code between projects, maintain consistency, and scale - your development with a modern monorepo setup. -

-
- - - -
-
- -
- - -

- Edit{" "} - src/routes/index.tsx{" "} - to customize this page -

-
+ +
+ +
); diff --git a/packages/env/src/env.ts b/packages/env/src/env.ts index 97c563795..71e022ccc 100644 --- a/packages/env/src/env.ts +++ b/packages/env/src/env.ts @@ -21,6 +21,7 @@ const ServerEnv = type({ "+": "delete", "[symbol]": "unknown", DATABASE_URL: "string>1", + GOOGLE_GENERATIVE_AI_API_KEY: "string>1", }); export const parseServerEnv = (env: unknown) => { const result = ServerEnv(env); diff --git a/packages/ui/package.json b/packages/ui/package.json index 39598911f..3a4f36837 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,7 +3,10 @@ "version": "0.1.0", "private": true, "sideEffects": false, - "files": ["tailwind.config.web.ts", "globals.css"], + "files": [ + "tailwind.config.web.ts", + "globals.css" + ], "exports": { "./*": "./src/*.tsx", "./styles.css": "./src/styles.css", @@ -65,12 +68,16 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.488.0", + "marked": "^15.0.8", "motion": "^12.6.5", "next-themes": "^0.4.6", "react-dropzone": "^14.3.8", "react-hook-form": "^7.55.0", + "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.7", "recharts": "^2.15.2", + "remark-gfm": "^4.0.1", + "shiki": "^3.2.2", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", diff --git a/packages/ui/src/components/chat/chat-input.tsx b/packages/ui/src/components/chat/chat-input.tsx new file mode 100644 index 000000000..2abcb7c61 --- /dev/null +++ b/packages/ui/src/components/chat/chat-input.tsx @@ -0,0 +1,198 @@ +"use client"; + +import type React from "react"; +import { createContext, useContext } from "react"; +import { useTextareaResize } from "../../hooks/use-textarea-resize"; +import { cn } from "../../utils/cn"; +import { ArrowUp } from "../icon"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; + +interface ChatInputContextValue { + value?: string | undefined; + onChange?: React.ChangeEventHandler | undefined; + onSubmit?: (() => void) | undefined; + loading?: boolean | undefined; + onStop?: (() => void) | undefined; + variant?: "default" | "unstyled" | undefined; + rows?: number | undefined; +} + +const ChatInputContext = createContext({}); + +interface ChatInputProps extends Omit { + children: React.ReactNode; + className?: string; + variant?: "default" | "unstyled"; + rows?: number; +} + +function ChatInput({ + children, + className, + variant = "default", + value, + onChange, + onSubmit, + loading, + onStop, + rows = 1, +}: ChatInputProps) { + return ( + +
+ {children} +
+
+ ); +} + +ChatInput.displayName = "ChatInput"; + +interface ChatInputTextAreaProps extends React.ComponentProps { + value?: string; + onChange?: React.ChangeEventHandler; + onSubmit?: () => void; + variant?: "default" | "unstyled"; +} + +function ChatInputTextArea({ + onSubmit: onSubmitProp, + value: valueProp, + onChange: onChangeProp, + className, + variant: variantProp, + ...props +}: ChatInputTextAreaProps) { + const context = useContext(ChatInputContext); + const value = valueProp ?? context.value ?? ""; + const onChange = onChangeProp ?? context.onChange; + const onSubmit = onSubmitProp ?? context.onSubmit; + const rows = context.rows ?? 1; + + // Convert parent variant to textarea variant unless explicitly overridden + const variant = + variantProp ?? (context.variant === "default" ? "unstyled" : "default"); + + const textareaRef = useTextareaResize(value, rows); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!onSubmit) { + return; + } + if (e.key === "Enter" && !e.shiftKey) { + if (typeof value !== "string" || value.trim().length === 0) { + return; + } + e.preventDefault(); + onSubmit(); + } + }; + + return ( +