first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+83
View File
@@ -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;
+137
View File
@@ -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
>;
+447
View File
@@ -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;
+46
View File
@@ -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,
)}`;
}
+12
View File
@@ -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}'),
'',
);
});
});
+186
View File
@@ -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'
);
}
+286
View File
@@ -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, '\\$&');
}
+61
View File
@@ -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'],
);
});
});
+51
View File
@@ -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);
}
+93
View File
@@ -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'];