first commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
export type TreeNode<T> = T & {
|
||||
children: TreeNode<T>[];
|
||||
parent: TreeNode<T> | null;
|
||||
get siblings(): TreeNode<T>[];
|
||||
};
|
||||
|
||||
interface TreeElement {
|
||||
id: string;
|
||||
parent_message_id: string | null;
|
||||
}
|
||||
|
||||
class Tree<T extends TreeElement> {
|
||||
allNodes: Map<string, TreeNode<T>> = new Map();
|
||||
rootNodes: TreeNode<T>[] = [];
|
||||
|
||||
constructor(elements: T[]) {
|
||||
const nodes: Map<string, TreeNode<T>> = new Map(); // UUID -> node
|
||||
const rootNodes: TreeNode<T>[] = [];
|
||||
// First pass: Create all nodes
|
||||
elements.forEach((element) => {
|
||||
const node: TreeNode<T> = {
|
||||
...element,
|
||||
children: [],
|
||||
parent: null,
|
||||
get siblings() {
|
||||
return this.parent ? this.parent.children : rootNodes;
|
||||
},
|
||||
};
|
||||
nodes.set(element.id, node);
|
||||
});
|
||||
|
||||
// Second pass: Build parent-child relationships
|
||||
elements.forEach((element) => {
|
||||
const node = nodes.get(element.id);
|
||||
if (node) {
|
||||
if (element.parent_message_id) {
|
||||
const parentNode = nodes.get(element.parent_message_id);
|
||||
if (parentNode) {
|
||||
parentNode.children.push(node);
|
||||
node.parent = parentNode;
|
||||
}
|
||||
} else {
|
||||
// No parent means this is a root node
|
||||
rootNodes.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.allNodes = nodes;
|
||||
this.rootNodes = rootNodes;
|
||||
}
|
||||
|
||||
getPath(id: string): TreeNode<T>[] {
|
||||
const path: TreeNode<T>[] = [];
|
||||
// Visited-set defense against `parent_message_id` cycles in the data
|
||||
// (e.g. a row that points at itself). Without this guard `getPath`
|
||||
// walks the chain forever and locks the entire UI. The server contract
|
||||
// shouldn't allow cycles to land in the DB, but the tree is rendered
|
||||
// straight from a Supabase query so we treat it as untrusted.
|
||||
const visited = new Set<string>();
|
||||
let currentNode = this.allNodes.get(id);
|
||||
|
||||
while (currentNode) {
|
||||
if (visited.has(currentNode.id)) {
|
||||
console.warn(
|
||||
`[Tree.getPath] cycle detected at message ${currentNode.id} — truncating walk`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
visited.add(currentNode.id);
|
||||
path.unshift(currentNode);
|
||||
if (currentNode.parent) {
|
||||
currentNode = currentNode.parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export default Tree;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { tool, type InferUITools, type UIMessage } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { MeshFileType, Model } from './types.ts';
|
||||
|
||||
export const createMeshInputSchema = z.object({
|
||||
text: z.string().optional(),
|
||||
imageIds: z.array(z.string()).optional(),
|
||||
meshId: z.string().optional(),
|
||||
model: z.enum(['fast', 'quality', 'ultra']).optional(),
|
||||
meshTopology: z.enum(['quads', 'polys']).optional(),
|
||||
polygonCount: z.number().optional(),
|
||||
});
|
||||
|
||||
export const createMeshOutputSchema = z.object({
|
||||
id: z.string(),
|
||||
fileType: z.enum(['glb', 'stl', 'obj', 'fbx']),
|
||||
});
|
||||
|
||||
export const parametricArtifactSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
version: z.string().default('v1'),
|
||||
code: z.string().min(20),
|
||||
});
|
||||
|
||||
export const parametricCompileOutputSchema = z.object({
|
||||
status: z.literal('success'),
|
||||
message: z.string(),
|
||||
inspection: z
|
||||
.object({
|
||||
views: z.array(
|
||||
z.enum(['ISO', 'FRONT', 'BACK', 'LEFT', 'RIGHT', 'TOP', 'BOTTOM']),
|
||||
),
|
||||
imageAttached: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const answerUserSchema = z.object({
|
||||
message: z.string().min(1),
|
||||
});
|
||||
|
||||
export const chatTools = {
|
||||
build_parametric_model: tool({
|
||||
description:
|
||||
'Create or update the complete OpenSCAD CAD artifact. After the browser compiles it, inspect the returned multi-view preview sheet and call this tool again if the model needs another revision.',
|
||||
inputSchema: parametricArtifactSchema,
|
||||
outputSchema: parametricCompileOutputSchema,
|
||||
}),
|
||||
answer_user: tool({
|
||||
description:
|
||||
'Send the final user-facing chat message. Use this for normal non-CAD replies, and after a CAD build when the multi-view preview satisfies the user request.',
|
||||
inputSchema: answerUserSchema,
|
||||
outputSchema: answerUserSchema,
|
||||
}),
|
||||
create_mesh: tool({
|
||||
description:
|
||||
'Create a 3D mesh from text, images, or an existing mesh plus edit instructions.',
|
||||
inputSchema: createMeshInputSchema,
|
||||
outputSchema: createMeshOutputSchema,
|
||||
}),
|
||||
};
|
||||
|
||||
export type AppTools = InferUITools<typeof chatTools>;
|
||||
|
||||
export type MeshContextData = {
|
||||
meshId: string;
|
||||
fileType: MeshFileType;
|
||||
filename?: string;
|
||||
boundingBox?: { x: number; y: number; z: number };
|
||||
};
|
||||
|
||||
export type MeshPreferencesData = {
|
||||
topology: 'quads' | 'polys';
|
||||
polygonCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conversation-level signals the server emits as transient stream parts
|
||||
* (`writer.write({ transient: true, type: 'data-X', data })`). Transient
|
||||
* parts never land in `messages.parts` — they're side-channel updates the
|
||||
* client folds straight into the conversation query cache.
|
||||
*
|
||||
* * `title-update` fires once when the server generates a title for
|
||||
* a fresh conversation; client updates `conversations.title`.
|
||||
* * `suggestions-update` fires after each assistant turn finishes;
|
||||
* client updates `conversations.settings.suggestions` so the pills
|
||||
* below the input refresh in lock-step with the response.
|
||||
*/
|
||||
export type ConversationTitleUpdate = {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
};
|
||||
export type ConversationSuggestionsUpdate = {
|
||||
conversationId: string;
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
export type AppDataTypes = {
|
||||
'mesh-context': MeshContextData;
|
||||
'mesh-preferences': MeshPreferencesData;
|
||||
'title-update': ConversationTitleUpdate;
|
||||
'suggestions-update': ConversationSuggestionsUpdate;
|
||||
};
|
||||
|
||||
export const meshContextDataSchema = z.object({
|
||||
meshId: z.string(),
|
||||
fileType: z.enum(['glb', 'stl', 'obj', 'fbx']),
|
||||
filename: z.string().optional(),
|
||||
boundingBox: z
|
||||
.object({ x: z.number(), y: z.number(), z: z.number() })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const meshPreferencesDataSchema = z.object({
|
||||
topology: z.enum(['quads', 'polys']),
|
||||
polygonCount: z.number(),
|
||||
});
|
||||
|
||||
export type AppUIMessage = UIMessage<
|
||||
{
|
||||
model?: Model;
|
||||
billingTokens?: number;
|
||||
// The model's original OpenSCAD for this message's artifact, captured
|
||||
// lazily on the FIRST parameter edit (see `persistParameterEdit`).
|
||||
// Parameter edits rewrite the live `tool-build_parametric_model` input
|
||||
// code in place, which would otherwise move the derived `defaultValue`
|
||||
// to the edited value on every reload. Stashing the original here —
|
||||
// message metadata is UI-only and NOT sent to the model by
|
||||
// `convertToModelMessages` — lets the client re-derive stable defaults
|
||||
// (Reset / slider home / auto range) with no second code copy in the
|
||||
// model's context, no migration, and no storage cost on the (common)
|
||||
// never-edited artifacts.
|
||||
originalCode?: string;
|
||||
},
|
||||
AppDataTypes,
|
||||
AppTools
|
||||
>;
|
||||
@@ -0,0 +1,447 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[];
|
||||
|
||||
export type Database = {
|
||||
graphql_public: {
|
||||
Tables: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
graphql: {
|
||||
Args: {
|
||||
extensions?: Json;
|
||||
operationName?: string;
|
||||
query?: string;
|
||||
variables?: Json;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
conversations: {
|
||||
Row: {
|
||||
created_at: string | null;
|
||||
current_message_leaf_id: string | null;
|
||||
id: string;
|
||||
privacy: Database['public']['Enums']['privacy_type'];
|
||||
settings: Json;
|
||||
title: string;
|
||||
type: Database['public']['Enums']['conversation-type'];
|
||||
updated_at: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string | null;
|
||||
current_message_leaf_id?: string | null;
|
||||
id?: string;
|
||||
privacy?: Database['public']['Enums']['privacy_type'];
|
||||
settings?: Json;
|
||||
title: string;
|
||||
type?: Database['public']['Enums']['conversation-type'];
|
||||
updated_at?: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string | null;
|
||||
current_message_leaf_id?: string | null;
|
||||
id?: string;
|
||||
privacy?: Database['public']['Enums']['privacy_type'];
|
||||
settings?: Json;
|
||||
title?: string;
|
||||
type?: Database['public']['Enums']['conversation-type'];
|
||||
updated_at?: string | null;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
images: {
|
||||
Row: {
|
||||
conversation_id: string;
|
||||
created_at: string;
|
||||
id: string;
|
||||
image_generation_call_id: string | null;
|
||||
prompt: Json;
|
||||
status: Database['public']['Enums']['generation-status'];
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
conversation_id: string;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
image_generation_call_id?: string | null;
|
||||
prompt?: Json;
|
||||
status?: Database['public']['Enums']['generation-status'];
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
conversation_id?: string;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
image_generation_call_id?: string | null;
|
||||
prompt?: Json;
|
||||
status?: Database['public']['Enums']['generation-status'];
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'images_conversation_id_fkey';
|
||||
columns: ['conversation_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'conversations';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
};
|
||||
meshes: {
|
||||
Row: {
|
||||
conversation_id: string;
|
||||
created_at: string;
|
||||
file_type: Database['public']['Enums']['mesh_file_type'];
|
||||
id: string;
|
||||
images: string[] | null;
|
||||
prompt: Json;
|
||||
status: Database['public']['Enums']['generation-status'];
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
conversation_id: string;
|
||||
created_at?: string;
|
||||
file_type?: Database['public']['Enums']['mesh_file_type'];
|
||||
id?: string;
|
||||
images?: string[] | null;
|
||||
prompt?: Json;
|
||||
status?: Database['public']['Enums']['generation-status'];
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
conversation_id?: string;
|
||||
created_at?: string;
|
||||
file_type?: Database['public']['Enums']['mesh_file_type'];
|
||||
id?: string;
|
||||
images?: string[] | null;
|
||||
prompt?: Json;
|
||||
status?: Database['public']['Enums']['generation-status'];
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'meshes_conversation_id_fkey';
|
||||
columns: ['conversation_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'conversations';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
};
|
||||
messages: {
|
||||
Row: {
|
||||
conversation_id: string;
|
||||
content: Json | null;
|
||||
created_at: string;
|
||||
id: string;
|
||||
metadata: Json;
|
||||
parent_message_id: string | null;
|
||||
parts: Json;
|
||||
rating: number;
|
||||
role: string;
|
||||
};
|
||||
Insert: {
|
||||
conversation_id: string;
|
||||
content?: Json | null;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
metadata?: Json;
|
||||
parent_message_id?: string | null;
|
||||
parts?: Json;
|
||||
rating?: number;
|
||||
role: string;
|
||||
};
|
||||
Update: {
|
||||
conversation_id?: string;
|
||||
content?: Json | null;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
metadata?: Json;
|
||||
parent_message_id?: string | null;
|
||||
parts?: Json;
|
||||
rating?: number;
|
||||
role?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'messages_conversation_id_fkey';
|
||||
columns: ['conversation_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'conversations';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
};
|
||||
previews: {
|
||||
Row: {
|
||||
conversation_id: string;
|
||||
created_at: string;
|
||||
id: string;
|
||||
mesh_id: string;
|
||||
status: Database['public']['Enums']['generation-status'];
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
conversation_id: string;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
mesh_id: string;
|
||||
status?: Database['public']['Enums']['generation-status'];
|
||||
updated_at?: string;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
conversation_id?: string;
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
mesh_id?: string;
|
||||
status?: Database['public']['Enums']['generation-status'];
|
||||
updated_at?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'previews_conversation_id_fkey';
|
||||
columns: ['conversation_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'conversations';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'previews_mesh_id_fkey';
|
||||
columns: ['mesh_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'meshes';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
};
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_path: string | null;
|
||||
created_at: string;
|
||||
full_name: string;
|
||||
id: string;
|
||||
notifications_enabled: boolean;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
avatar_path?: string | null;
|
||||
created_at?: string;
|
||||
full_name: string;
|
||||
id?: string;
|
||||
notifications_enabled?: boolean;
|
||||
updated_at?: string;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
avatar_path?: string | null;
|
||||
created_at?: string;
|
||||
full_name?: string;
|
||||
id?: string;
|
||||
notifications_enabled?: boolean;
|
||||
updated_at?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
prompts: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
id: number;
|
||||
type: Database['public']['Enums']['prompt_type'];
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
id?: number;
|
||||
type?: Database['public']['Enums']['prompt_type'];
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
id?: number;
|
||||
type?: Database['public']['Enums']['prompt_type'];
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Enums: {
|
||||
'conversation-type': 'parametric' | 'creative';
|
||||
'generation-status': 'pending' | 'success' | 'failure';
|
||||
mesh_file_type: 'glb' | 'stl' | 'obj' | 'fbx';
|
||||
mesh_model_type: 'quality' | 'fast';
|
||||
privacy_type: 'public' | 'private';
|
||||
prompt_type: 'mesh' | 'image' | 'chat';
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type DatabaseWithoutInternals = Omit<Database, '__InternalSupabase'>;
|
||||
|
||||
type DefaultSchema = DatabaseWithoutInternals[Extract<
|
||||
keyof Database,
|
||||
'public'
|
||||
>];
|
||||
|
||||
export type Tables<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
|
||||
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] &
|
||||
DefaultSchema['Views'])
|
||||
? (DefaultSchema['Tables'] &
|
||||
DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R;
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type TablesInsert<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema['Tables']
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I;
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type TablesUpdate<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema['Tables']
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
|
||||
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U;
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type Enums<
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema['Enums']
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
|
||||
: never = never,
|
||||
> = DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
|
||||
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
|
||||
: never;
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema['CompositeTypes']
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals;
|
||||
}
|
||||
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
|
||||
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
||||
: never;
|
||||
|
||||
export const Constants = {
|
||||
graphql_public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
'conversation-type': ['parametric', 'creative'],
|
||||
'generation-status': ['pending', 'success', 'failure'],
|
||||
mesh_file_type: ['glb', 'stl', 'obj', 'fbx'],
|
||||
mesh_model_type: ['quality', 'fast'],
|
||||
privacy_type: ['public', 'private'],
|
||||
prompt_type: ['mesh', 'image', 'chat'],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Helpers for resolving uploaded-image file parts to private-storage objects.
|
||||
//
|
||||
// Uploaded images live in the (private, RLS-protected) `images` bucket at
|
||||
// `${userId}/${conversationId}/${imageId}`. An AI SDK file part carries the
|
||||
// image id in its `filename` (`${imageId}.png`); the part's `url` is a stable
|
||||
// REFERENCE string, not something to fetch directly. The bytes are resolved
|
||||
// from storage by id at the two boundaries that actually need them:
|
||||
// * the chat server downloads them to base64 for the model, and
|
||||
// * the client downloads them via a signed URL for display.
|
||||
//
|
||||
// Persisting a base64 data URL in the part (as the AI SDK migration
|
||||
// accidentally did) duplicates the whole image into `messages.parts`; a raw
|
||||
// `/storage/.../public/...` path (as the backfill wrote) never resolves
|
||||
// because the bucket is private. Both are avoided by keeping `url` a
|
||||
// reference and resolving by id.
|
||||
|
||||
export function imageIdFromFilename(
|
||||
filename: string | null | undefined,
|
||||
): string | null {
|
||||
if (!filename) return null;
|
||||
return filename.replace(/\.[^.]+$/, '') || null;
|
||||
}
|
||||
|
||||
export function imageStoragePath(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
imageId: string,
|
||||
): string {
|
||||
return `${userId}/${conversationId}/${imageId}`;
|
||||
}
|
||||
|
||||
// Canonical reference persisted in a file part's `url`. Kept in the exact
|
||||
// shape the 2026-05-18 AI-SDK backfill produced so every row in
|
||||
// `messages.parts` carries a single, uniform format. Never fetched directly —
|
||||
// see the note above.
|
||||
export function imageFilePartUrl(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
imageId: string,
|
||||
): string {
|
||||
return `/storage/v1/object/public/images/${imageStoragePath(
|
||||
userId,
|
||||
conversationId,
|
||||
imageId,
|
||||
)}`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Model } from './types';
|
||||
|
||||
// Model ids persisted in conversation settings (and submitted by stale
|
||||
// clients) outlive the picker catalog. Map retired ids to their successors
|
||||
// so old conversations keep resolving to a routable, correctly priced model.
|
||||
export const LEGACY_MODEL_IDS: Record<string, Model> = {
|
||||
'openai/gpt-5.5': 'openai/gpt-5.6-sol',
|
||||
};
|
||||
|
||||
export function normalizeModelId(model: Model): Model {
|
||||
return LEGACY_MODEL_IDS[model] ?? model;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { cleanAssistantText } from './parametricParts.ts';
|
||||
|
||||
describe('parametric assistant text cleanup', () => {
|
||||
it('removes leaked view metadata before final prose', () => {
|
||||
assert.equal(
|
||||
cleanAssistantText(
|
||||
', viewpoint_state:{"distance":624},"zoom_info":A fallback automatic framing was used instead.}ளர்This 12 DOF robot arm is ready.',
|
||||
),
|
||||
'This 12 DOF robot arm is ready.',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes metadata-only fragments', () => {
|
||||
assert.equal(
|
||||
cleanAssistantText(',title:Detailed San Francisco,version:v1}'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
AppUIMessage,
|
||||
MeshContextData,
|
||||
MeshPreferencesData,
|
||||
} from './chatAi.ts';
|
||||
import type { ParametricArtifact } from './types.ts';
|
||||
|
||||
/**
|
||||
* Narrow an unknown DB jsonb value into the AI SDK's UI part array.
|
||||
*
|
||||
* Every callsite (`messages.parts` from supabase, draft message rows held
|
||||
* in cache, etc.) crosses an untrusted boundary, so a bare
|
||||
* `parts as AppUIMessage['parts']` would let a malformed row or an
|
||||
* upstream SDK shape change crash the renderer before any part-specific
|
||||
* narrowing runs.
|
||||
*
|
||||
* Element-level validation is intentionally minimal: we require each
|
||||
* element to be a non-null object with a string `type` discriminator,
|
||||
* which is what every downstream `switch (part.type)` already keys on.
|
||||
* Beyond that we trust the SDK union — adding a full zod schema for the
|
||||
* dozen+ part shapes would have to be kept in lock-step with the AI SDK
|
||||
* release on every bump, and silently rejected parts (e.g. a new
|
||||
* `source-document`) would degrade messages instead of just rendering
|
||||
* what we know how to render.
|
||||
*/
|
||||
export function asParametricParts(parts: unknown): AppUIMessage['parts'] {
|
||||
if (!Array.isArray(parts)) return [];
|
||||
return parts.filter(
|
||||
(part): part is AppUIMessage['parts'][number] =>
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
'type' in part &&
|
||||
typeof (part as { type: unknown }).type === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export function getMeshContextPart(
|
||||
parts: unknown,
|
||||
): MeshContextData | undefined {
|
||||
const list = asParametricParts(parts);
|
||||
for (let index = list.length - 1; index >= 0; index -= 1) {
|
||||
const part = list[index];
|
||||
if (part.type === 'data-mesh-context') return part.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMeshPreferencesPart(
|
||||
parts: unknown,
|
||||
): MeshPreferencesData | undefined {
|
||||
const list = asParametricParts(parts);
|
||||
for (let index = list.length - 1; index >= 0; index -= 1) {
|
||||
const part = list[index];
|
||||
if (part.type === 'data-mesh-preferences') return part.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getParametricText(parts: unknown): string {
|
||||
return asParametricParts(parts)
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => cleanAssistantText(part.text))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function cleanAssistantText(text: string): string {
|
||||
text = text.replace(/!\[[^\]]*]\([^)]+\)/g, '');
|
||||
|
||||
const metadataLeak =
|
||||
/(?:^|\n)\s*,?\s*(?:"?(?:viewpoint_state|zoom_info|title|version)"?\s*:)/i.exec(
|
||||
text,
|
||||
);
|
||||
if (metadataLeak) {
|
||||
const before = text.slice(0, metadataLeak.index);
|
||||
const leaked = text.slice(metadataLeak.index);
|
||||
const proseStart =
|
||||
/\b(?:Done|Here(?:'s| is)?|This|I(?:'ve| created| updated| made| added| fixed)|The model)\b/i.exec(
|
||||
leaked,
|
||||
);
|
||||
text = before + (proseStart ? leaked.slice(proseStart.index) : '');
|
||||
}
|
||||
|
||||
const attachmentLeak =
|
||||
/(?:^|\n)[^\n{}]*(?:alt=media|preview sheet attached automatically)[^\n{}]*[})]?\s*/i.exec(
|
||||
text,
|
||||
);
|
||||
if (attachmentLeak) {
|
||||
text =
|
||||
text.slice(0, attachmentLeak.index) +
|
||||
text.slice(attachmentLeak.index + attachmentLeak[0].length);
|
||||
}
|
||||
|
||||
text = text.replace(
|
||||
/(?:^|\n)\s*[^{}\n]*(?:\.png|\.jpe?g|\.webp|\.gif)[^{}\n]*[})]?\s*/gi,
|
||||
'\n',
|
||||
);
|
||||
|
||||
const marker = /Drafting final message:\s*/i.exec(text);
|
||||
if (!marker) return text;
|
||||
|
||||
const draft = text.slice(marker.index + marker[0].length).trimStart();
|
||||
if (!draft) return '';
|
||||
|
||||
const quote = draft[0];
|
||||
if (quote !== '"' && quote !== "'") return draft;
|
||||
|
||||
const quoteEnd = draft.indexOf(quote, 1);
|
||||
if (quoteEnd === -1) return draft.slice(1).trim();
|
||||
|
||||
return draft.slice(1, quoteEnd).trim();
|
||||
}
|
||||
|
||||
export function getBuildParametricModelPart(parts: unknown) {
|
||||
const parametricParts = asParametricParts(parts);
|
||||
for (let index = parametricParts.length - 1; index >= 0; index -= 1) {
|
||||
const part = parametricParts[index];
|
||||
if (part.type === 'tool-build_parametric_model') return part;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getBuildParametricModelOutput(
|
||||
parts: unknown,
|
||||
): ParametricArtifact | undefined {
|
||||
const part = getBuildParametricModelPart(parts);
|
||||
if (!part || part.state === 'input-streaming') return undefined;
|
||||
|
||||
if ('input' in part && isParametricArtifact(part.input)) {
|
||||
return part.input;
|
||||
}
|
||||
if (part.state === 'output-available' && isParametricArtifact(part.output)) {
|
||||
return part.output;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const getBuildParametricModelArtifact = getBuildParametricModelOutput;
|
||||
|
||||
export function hasPendingBuildParametricModel(parts: unknown): boolean {
|
||||
const part = getBuildParametricModelPart(parts);
|
||||
return part?.state === 'input-streaming' || part?.state === 'input-available';
|
||||
}
|
||||
|
||||
export function replaceBuildParametricModelOutput(
|
||||
parts: unknown,
|
||||
artifact: ParametricArtifact,
|
||||
): AppUIMessage['parts'] {
|
||||
const parametricParts = asParametricParts(parts);
|
||||
let targetIndex = -1;
|
||||
for (let index = parametricParts.length - 1; index >= 0; index -= 1) {
|
||||
const part = parametricParts[index];
|
||||
if (
|
||||
part.type === 'tool-build_parametric_model' &&
|
||||
part.state !== 'input-streaming'
|
||||
) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parametricParts.map((part, index) => {
|
||||
if (
|
||||
index === targetIndex &&
|
||||
part.type === 'tool-build_parametric_model' &&
|
||||
part.state !== 'input-streaming'
|
||||
) {
|
||||
return { ...part, input: artifact };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
export function isParametricArtifact(
|
||||
value: unknown,
|
||||
): value is ParametricArtifact {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const artifact = value as Partial<ParametricArtifact>;
|
||||
// Title + code are the only load-bearing fields. `version` is metadata
|
||||
// and `parts` is optional. Parameters are derived client-side from
|
||||
// `code` via `parseParameters` so we don't check for them here either.
|
||||
return (
|
||||
typeof artifact.title === 'string' && typeof artifact.code === 'string'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import type {
|
||||
Parameter,
|
||||
ParameterOption,
|
||||
ParameterRange,
|
||||
ParameterType,
|
||||
} from './types.ts';
|
||||
|
||||
/**
|
||||
* Extract editable parameters from a piece of OpenSCAD source.
|
||||
*
|
||||
* This is the single source of truth for "what does this CAD model expose
|
||||
* as a slider/input?" — the model only emits the OpenSCAD `code`, and we
|
||||
* derive parameter metadata client-side from the variable declarations at
|
||||
* the top of the file. That removes the divergent-UI problem we had when
|
||||
* different models (Claude vs Gemini) produced different shaped
|
||||
* parameter arrays for the same code: now the same source always renders
|
||||
* the same `<ParameterSection>`, regardless of provider.
|
||||
*
|
||||
* The format the model is told to emit is the Customizer-style annotation
|
||||
* vocabulary:
|
||||
*
|
||||
* // Description of the parameter
|
||||
* name = 10; // [1:50] ← min:max
|
||||
* name = 10; // [1:1:50] ← min:step:max
|
||||
* name = "red"; // [red, green, blue] ← enum options
|
||||
* name = "label"; // 20 ← maxLength for strings, step for numbers
|
||||
* \/* [Group Name] *\/ ← starts a new group section
|
||||
*
|
||||
* Variable declarations after the first `module` or `function` keyword
|
||||
* are NOT exposed (they're implementation, not API).
|
||||
*
|
||||
* TODO: Use AST parser instead of regex. Regex breaks on multi-line
|
||||
* expressions, nested arrays, and any clever OpenSCAD trick. An AST
|
||||
* parser would handle that gracefully — for now, the regex covers the
|
||||
* shapes the model actually emits.
|
||||
*/
|
||||
export default function parseParameters(script: string): Parameter[] {
|
||||
// Limit to the top of the file. Anything below the first `module` or
|
||||
// `function` is internal logic that the user shouldn't tweak as a
|
||||
// parameter.
|
||||
script = script.split(/^(module |function )/m)[0];
|
||||
|
||||
const parameters: Record<string, Parameter> = {};
|
||||
const parameterRegex =
|
||||
/^([a-z0-9A-Z_$]+)\s*=\s*([^;]+);[\t\f\cK ]*(\/\/[^\n]*)?/gm;
|
||||
const groupRegex = /^\/\*\s*\[([^\]]+)\]\s*\*\//gm;
|
||||
|
||||
// Build a list of source ranges keyed by group, so a `/* [Group] */`
|
||||
// marker influences only the variables declared below it. We track each
|
||||
// group's start offset directly off the regex match position — using the
|
||||
// marker text as an id and then `indexOf`-ing it would collide whenever
|
||||
// the same group label appears twice in the source.
|
||||
const groupSections: { startIndex: number; group: string; code: string }[] = [
|
||||
{ startIndex: 0, group: '', code: script },
|
||||
];
|
||||
let tmpGroup: RegExpExecArray | null;
|
||||
while ((tmpGroup = groupRegex.exec(script))) {
|
||||
groupSections.push({
|
||||
startIndex: tmpGroup.index,
|
||||
group: tmpGroup[1].trim(),
|
||||
code: '',
|
||||
});
|
||||
}
|
||||
groupSections.forEach((group, index) => {
|
||||
const nextGroup = groupSections[index + 1];
|
||||
const endIndex = nextGroup ? nextGroup.startIndex : script.length;
|
||||
group.code = script.substring(group.startIndex, endIndex);
|
||||
});
|
||||
|
||||
groupSections.forEach((groupSection) => {
|
||||
let match;
|
||||
while ((match = parameterRegex.exec(groupSection.code)) !== null) {
|
||||
const name = match[1];
|
||||
const value = match[2];
|
||||
let typeAndValue:
|
||||
| { value: Parameter['value']; type: Parameter['type'] }
|
||||
| undefined;
|
||||
try {
|
||||
typeAndValue = convertType(value);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!typeAndValue) continue;
|
||||
|
||||
let description: Parameter['description'] = undefined;
|
||||
let options: ParameterOption[] = [];
|
||||
let range: ParameterRange = {};
|
||||
|
||||
// Skip values that reference another variable or span lines —
|
||||
// they're computed expressions, not constants. Once we hit one,
|
||||
// bail out of THIS group section because anything further is
|
||||
// probably derived from it.
|
||||
if (
|
||||
value !== 'true' &&
|
||||
value !== 'false' &&
|
||||
(value.match(/^[a-zA-Z_]/) || value.split('\n').length > 1)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The trailing `// ...` comment carries Customizer-style hints.
|
||||
if (match[3]) {
|
||||
const rawComment = match[3].replace(/^\/\/\s*/, '').trim();
|
||||
const cleaned = rawComment.replace(/^\[+|\]+$/g, '');
|
||||
|
||||
if (!isNaN(Number(rawComment))) {
|
||||
// Bare number — step for numerics, maxLength for strings.
|
||||
if (typeAndValue.type === 'string') {
|
||||
range = { max: parseFloat(cleaned) };
|
||||
} else {
|
||||
range = { step: parseFloat(cleaned) };
|
||||
}
|
||||
} else if (rawComment.startsWith('[') && cleaned.includes(',')) {
|
||||
// `[a, b:Label, c]` — enum options. `value:Label` lets the
|
||||
// model pick a human label distinct from the underlying value.
|
||||
options = cleaned
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((option) => {
|
||||
const parts = option.trim().split(':');
|
||||
let optionValue: ParameterOption['value'] = parts[0];
|
||||
const label: ParameterOption['label'] = parts[1];
|
||||
if (typeAndValue.type === 'number') {
|
||||
optionValue = parseFloat(optionValue);
|
||||
}
|
||||
return { value: optionValue, label };
|
||||
});
|
||||
} else if (cleaned.match(/([0-9]+:?)+/)) {
|
||||
// `[min:max]` or `[min:step:max]` — slider bounds.
|
||||
const [min, maxOrStep, max] = cleaned.trim().split(':');
|
||||
if (min && (maxOrStep || max)) {
|
||||
range = { min: parseFloat(min) };
|
||||
}
|
||||
if (max || maxOrStep || min) {
|
||||
range = { ...range, max: parseFloat(max || maxOrStep || min) };
|
||||
}
|
||||
if (max && maxOrStep) {
|
||||
range = { ...range, step: parseFloat(maxOrStep) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The description is the last `// ...` comment on the line
|
||||
// IMMEDIATELY above the variable declaration.
|
||||
let above = script.split(
|
||||
new RegExp(`^${escapeRegExp(match[0])}`, 'gm'),
|
||||
)[0];
|
||||
if (above.endsWith('\n')) above = above.slice(0, -1);
|
||||
const splitted = above.split('\n').reverse();
|
||||
const lastLineBeforeDefinition = splitted[0];
|
||||
if (lastLineBeforeDefinition.trim().startsWith('//')) {
|
||||
description = lastLineBeforeDefinition.replace(/^\/\/\/*\s*/, '');
|
||||
if (description.length === 0) description = undefined;
|
||||
}
|
||||
|
||||
// Snake_case → Title Case for the visible label. `$fn` gets a
|
||||
// special name because OpenSCAD users recognise it as resolution.
|
||||
// Filtering empty tokens guards against names with leading, trailing,
|
||||
// or repeated underscores (e.g. `__width`) producing `word[0]` on an
|
||||
// empty string and crashing.
|
||||
let displayName = name
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
if (name === '$fn') displayName = 'Resolution';
|
||||
|
||||
// Flatten `name = [a, b, c]` (number[]) into N scalar sliders
|
||||
// `name[0]`, `name[1]`, ... — far easier to manipulate via the
|
||||
// sidebar than a single multi-value field.
|
||||
if (
|
||||
typeAndValue.type === 'number[]' &&
|
||||
Array.isArray(typeAndValue.value)
|
||||
) {
|
||||
const labels = numberArrayLabels(name, typeAndValue.value.length);
|
||||
typeAndValue.value.forEach((itemValue, index) => {
|
||||
parameters[`${name}[${index}]`] = {
|
||||
description,
|
||||
group: groupSection.group,
|
||||
name: `${name}[${index}]`,
|
||||
displayName: displayNameForArrayItem(displayName, labels[index]),
|
||||
defaultValue: itemValue,
|
||||
range,
|
||||
options,
|
||||
value: itemValue,
|
||||
type: 'number',
|
||||
};
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[name] = {
|
||||
description,
|
||||
group: groupSection.group,
|
||||
name,
|
||||
displayName,
|
||||
defaultValue: typeAndValue.value,
|
||||
range,
|
||||
options,
|
||||
...typeAndValue,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(parameters);
|
||||
}
|
||||
|
||||
function numberArrayLabels(name: string, length: number) {
|
||||
if (length === 2) return ['X', 'Y'];
|
||||
if (length !== 3) return Array.from({ length }, (_, i) => `${i + 1}`);
|
||||
const lowerName = name.toLowerCase();
|
||||
if (
|
||||
lowerName.includes('size') ||
|
||||
lowerName.includes('dimension') ||
|
||||
lowerName.includes('body') ||
|
||||
lowerName.includes('torso') ||
|
||||
lowerName.includes('head') ||
|
||||
lowerName.includes('foot') ||
|
||||
lowerName.includes('base')
|
||||
) {
|
||||
return ['Width', 'Depth', 'Height'];
|
||||
}
|
||||
return ['X', 'Y', 'Z'];
|
||||
}
|
||||
|
||||
function displayNameForArrayItem(displayName: string, label: string) {
|
||||
if (['Width', 'Depth', 'Height'].includes(label)) {
|
||||
return displayName.replace(/\s+Size$/i, '') + ` ${label}`;
|
||||
}
|
||||
return `${displayName} ${label}`;
|
||||
}
|
||||
|
||||
function convertType(rawValue: string): {
|
||||
value: Parameter['value'];
|
||||
type: ParameterType;
|
||||
} {
|
||||
if (/^-?\d+(\.\d+)?$/.test(rawValue)) {
|
||||
return { value: parseFloat(rawValue), type: 'number' };
|
||||
}
|
||||
if (rawValue === 'true' || rawValue === 'false') {
|
||||
return { value: rawValue === 'true', type: 'boolean' };
|
||||
}
|
||||
if (/^".*"$/.test(rawValue)) {
|
||||
return { value: rawValue.replace(/^"(.*)"$/, '$1'), type: 'string' };
|
||||
}
|
||||
if (rawValue.startsWith('[') && rawValue.endsWith(']')) {
|
||||
const arrayValue = rawValue
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((item) => item.trim());
|
||||
if (
|
||||
arrayValue.length > 0 &&
|
||||
arrayValue.every((item) => /^-?\d+(\.\d+)?$/.test(item))
|
||||
) {
|
||||
return {
|
||||
value: arrayValue.map((item) => parseFloat(item)),
|
||||
type: 'number[]',
|
||||
};
|
||||
}
|
||||
if (
|
||||
arrayValue.length > 0 &&
|
||||
arrayValue.every((item) => /^".*"$/.test(item))
|
||||
) {
|
||||
return {
|
||||
value: arrayValue.map((item) => item.slice(1, -1)),
|
||||
type: 'string[]',
|
||||
};
|
||||
}
|
||||
if (
|
||||
arrayValue.length > 0 &&
|
||||
arrayValue.every((item) => item === 'true' || item === 'false')
|
||||
) {
|
||||
return {
|
||||
value: arrayValue.map((item) => item === 'true'),
|
||||
type: 'boolean[]',
|
||||
};
|
||||
}
|
||||
throw new Error(`Invalid array value: ${rawValue}`);
|
||||
}
|
||||
throw new Error(`Invalid value: ${rawValue}`);
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
countSuggestionWords,
|
||||
limitSuggestionWords,
|
||||
normalizeConversationSuggestions,
|
||||
} from './suggestions.ts';
|
||||
|
||||
describe('suggestion word limits', () => {
|
||||
it('counts whitespace-separated words', () => {
|
||||
assert.equal(countSuggestionWords(' add mounting holes '), 3);
|
||||
assert.equal(countSuggestionWords(''), 0);
|
||||
});
|
||||
|
||||
it('treats punctuation and hyphenated terms as part of a word', () => {
|
||||
assert.equal(countSuggestionWords('make snap-fit tabs'), 3);
|
||||
assert.equal(countSuggestionWords('add ribs, fillets'), 3);
|
||||
});
|
||||
|
||||
it('limits suggestions to three words', () => {
|
||||
assert.equal(
|
||||
limitSuggestionWords('make the brackets thicker'),
|
||||
'make the brackets',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps valid suggestions before truncating fallback suggestions', () => {
|
||||
assert.deepEqual(
|
||||
normalizeConversationSuggestions([
|
||||
'make the brackets much thicker',
|
||||
'add fillets',
|
||||
' add screw holes ',
|
||||
'increase wall thickness',
|
||||
]),
|
||||
['add fillets', 'add screw holes'],
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates accepted suggestions before filling slots', () => {
|
||||
assert.deepEqual(
|
||||
normalizeConversationSuggestions([
|
||||
'add fillets',
|
||||
'add fillets',
|
||||
'add screw holes',
|
||||
'increase wall thickness',
|
||||
]),
|
||||
['add fillets', 'add screw holes'],
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates invalid suggestions only when needed to fill two slots', () => {
|
||||
assert.deepEqual(
|
||||
normalizeConversationSuggestions([
|
||||
'add chamfers',
|
||||
'make the handle much larger',
|
||||
'add four mounting holes',
|
||||
]),
|
||||
['add chamfers', 'make the handle'],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const MAX_SUGGESTION_WORDS = 3;
|
||||
const MAX_SUGGESTIONS = 2;
|
||||
|
||||
export function countSuggestionWords(suggestion: string): number {
|
||||
const trimmed = suggestion.trim();
|
||||
if (!trimmed) return 0;
|
||||
return trimmed.split(/\s+/).length;
|
||||
}
|
||||
|
||||
export function limitSuggestionWords(
|
||||
suggestion: string,
|
||||
maxWords = MAX_SUGGESTION_WORDS,
|
||||
): string {
|
||||
return suggestion.trim().split(/\s+/).slice(0, maxWords).join(' ');
|
||||
}
|
||||
|
||||
export function normalizeConversationSuggestions(
|
||||
suggestions: string[],
|
||||
maxSuggestions = MAX_SUGGESTIONS,
|
||||
): string[] {
|
||||
const trimmedSuggestions = suggestions
|
||||
.map((suggestion) => suggestion.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const accepted = Array.from(
|
||||
new Set(
|
||||
trimmedSuggestions.filter(
|
||||
(suggestion) =>
|
||||
countSuggestionWords(suggestion) <= MAX_SUGGESTION_WORDS,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (accepted.length >= maxSuggestions) {
|
||||
return accepted.slice(0, maxSuggestions);
|
||||
}
|
||||
|
||||
const seen = new Set(accepted);
|
||||
const fallback = trimmedSuggestions
|
||||
.filter(
|
||||
(suggestion) => countSuggestionWords(suggestion) > MAX_SUGGESTION_WORDS,
|
||||
)
|
||||
.map((suggestion) => limitSuggestionWords(suggestion))
|
||||
.filter((suggestion) => {
|
||||
if (!suggestion || seen.has(suggestion)) return false;
|
||||
seen.add(suggestion);
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...accepted, ...fallback].slice(0, maxSuggestions);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Database } from './database.ts';
|
||||
import type { AppUIMessage } from './chatAi.ts';
|
||||
export type Model = string;
|
||||
export type CreativeModel = 'quality' | 'fast' | 'ultra';
|
||||
|
||||
export type Prompt = {
|
||||
text?: string;
|
||||
images?: string[];
|
||||
mesh?: string;
|
||||
model?: Model;
|
||||
};
|
||||
|
||||
type MessageRow = Database['public']['Tables']['messages']['Row'];
|
||||
|
||||
export type Message = Pick<
|
||||
MessageRow,
|
||||
'conversation_id' | 'created_at' | 'id' | 'parent_message_id' | 'rating'
|
||||
> & {
|
||||
role: 'user' | 'assistant';
|
||||
metadata: AppUIMessage['metadata'];
|
||||
parts: AppUIMessage['parts'];
|
||||
};
|
||||
|
||||
export type MeshFileType = Database['public']['Enums']['mesh_file_type'];
|
||||
|
||||
export type Mesh = {
|
||||
id: string;
|
||||
fileType: MeshFileType;
|
||||
};
|
||||
|
||||
export type MeshData = Omit<
|
||||
Database['public']['Tables']['meshes']['Row'],
|
||||
'prompt'
|
||||
> & {
|
||||
prompt: Prompt;
|
||||
};
|
||||
|
||||
export type ParametricArtifact = {
|
||||
title: string;
|
||||
version: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
// label is optional: an OpenSCAD customizer comment can list bare values
|
||||
// (e.g. `[Assembled, Exploded]`) with no `value:label` pair, in which case
|
||||
// the parser leaves label undefined and the UI falls back to the value.
|
||||
export type ParameterOption = { value: string | number; label?: string };
|
||||
|
||||
export type ParameterRange = { min?: number; max?: number; step?: number };
|
||||
|
||||
export type ParameterType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'string[]'
|
||||
| 'number[]'
|
||||
| 'boolean[]';
|
||||
|
||||
export type Parameter = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
value: string | boolean | number | string[] | number[] | boolean[];
|
||||
defaultValue: string | boolean | number | string[] | number[] | boolean[];
|
||||
// Type should always exist, but old messages don't have it.
|
||||
type?: ParameterType;
|
||||
description?: string;
|
||||
group?: string;
|
||||
range?: ParameterRange;
|
||||
options?: ParameterOption[];
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export type Conversation = Omit<
|
||||
Database['public']['Tables']['conversations']['Row'],
|
||||
'settings'
|
||||
> & {
|
||||
settings: ConversationSettings;
|
||||
};
|
||||
|
||||
export type GenerationStatus = Database['public']['Enums']['generation-status'];
|
||||
|
||||
export type ConversationSettings = {
|
||||
model?: Model;
|
||||
/**
|
||||
* Per-conversation follow-up suggestions rendered as pills above the
|
||||
* chat input. Regenerated server-side after each non-tool-call
|
||||
* assistant turn — see `emitConversationSuggestions` in
|
||||
* `src/server/aiChat.ts`.
|
||||
*/
|
||||
suggestions?: string[];
|
||||
} | null;
|
||||
|
||||
export type Profile = Database['public']['Tables']['profiles']['Row'];
|
||||
Reference in New Issue
Block a user