diff --git a/app/components/views/SettingsDrawer/RouteList.tsx b/app/components/views/SettingsDrawer/RouteList.tsx index 4d00b9c7..ede0ab46 100644 --- a/app/components/views/SettingsDrawer/RouteList.tsx +++ b/app/components/views/SettingsDrawer/RouteList.tsx @@ -96,6 +96,11 @@ const getPaths = (remote: boolean): ButtonData[] => [ path: '/screens/ModelManagerScreen', icon: 'folderopen', }, + { + name: 'Tools', + path: '/screens/ToolManagerScreen', + icon: 'tool', + }, { name: 'TTS', path: '/screens/TTSManagerScreen', diff --git a/app/screens/ChatScreen/ChatWindow/ChatBubble.tsx b/app/screens/ChatScreen/ChatWindow/ChatBubble.tsx index d6fe4ce1..068d8976 100644 --- a/app/screens/ChatScreen/ChatWindow/ChatBubble.tsx +++ b/app/screens/ChatScreen/ChatWindow/ChatBubble.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react' import { AppSettings } from '@lib/constants/GlobalValues' import { useAppMode } from '@lib/state/AppMode' import { Chats } from '@lib/state/Chat' import { Theme } from '@lib/theme/ThemeManager' +import { ToolCallData } from 'db/schema' import { Pressable, Text, View } from 'react-native' import { useMMKVBoolean } from 'react-native-mmkv' import { useShallow } from 'zustand/react/shallow' @@ -44,7 +46,11 @@ const ChatBubble: React.FC = ({ } const hasSwipes = message?.swipes?.length > 1 - const showSwipe = !message.is_user && isLastMessage && (hasSwipes || !isGreeting) + const isToolMessage = message.role === 'tool' + const showSwipe = + !message.is_user && !isToolMessage && isLastMessage && (hasSwipes || !isGreeting) + const currentSwipe = message.swipes[message.swipe_id] + const hasToolCalls = currentSwipe?.tool_calls && currentSwipe.tool_calls.length > 0 const timings = message.swipes[message.swipe_id].timings return ( @@ -73,6 +79,23 @@ const ChatBubble: React.FC = ({ ], }} onLongPress={handleEnableEdit}> + {hasToolCalls && ( + + )} + {isToolMessage && ( + + )} {isLastMessage ? ( ) : ( @@ -115,4 +138,74 @@ const getFiniteValue = (value: number | null) => { return value.toFixed(2) } +type ToolCallIndicatorProps = { + toolCalls: ToolCallData[] + color: any + fontSize: any + spacing: any + borderRadius: any +} + +const ToolCallIndicator = ({ + toolCalls, + color, + fontSize, + spacing, + borderRadius, +}: ToolCallIndicatorProps) => { + const [expanded, setExpanded] = useState(false) + return ( + setExpanded(!expanded)}> + + + {expanded ? '\u25BC' : '\u25B6'} Tool Call + {toolCalls.length > 1 ? 's' : ''}:{' '} + {toolCalls.map((tc) => tc.function.name).join(', ')} + + {expanded && + toolCalls.map((tc, i) => ( + + + {tc.function.name}({tc.function.arguments}) + + + ))} + + + ) +} + +type ToolResultHeaderProps = { + name: string + color: any + fontSize: any + spacing: any +} + +const ToolResultHeader = ({ name, color, fontSize, spacing }: ToolResultHeaderProps) => { + return ( + + + {'\u2699\uFE0F'} Tool Result: {name} + + + ) +} + export default ChatBubble diff --git a/app/screens/ToolManagerScreen/AddTool.tsx b/app/screens/ToolManagerScreen/AddTool.tsx new file mode 100644 index 00000000..6f689b4f --- /dev/null +++ b/app/screens/ToolManagerScreen/AddTool.tsx @@ -0,0 +1,132 @@ +import ThemedButton from '@components/buttons/ThemedButton' +import ThemedTextInput from '@components/input/ThemedTextInput' +import { Logger } from '@lib/state/Logger' +import { ToolState } from '@lib/state/ToolState' +import { Theme } from '@lib/theme/ThemeManager' +import { Stack, useRouter } from 'expo-router' +import { useState } from 'react' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +const PARAMS_PLACEHOLDER = `{"type":"object","properties":{"query":{"type":"string","description":"The search query"}},"required":["query"]}` + +const AddTool = () => { + const styles = useStyles() + const router = useRouter() + const addTool = ToolState.useToolStore((state) => state.addTool) + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [parametersJson, setParametersJson] = useState('') + const [error, setError] = useState('') + + const handleCreate = () => { + setError('') + + if (!name.trim()) { + setError('Name is required') + return + } + + if (!/^[a-z0-9_]+$/.test(name.trim())) { + setError('Name must be lowercase alphanumeric with underscores only') + return + } + + if (!description.trim()) { + setError('Description is required') + return + } + + let parsedParams: object + try { + parsedParams = JSON.parse(parametersJson.trim() || '{"type":"object","properties":{}}') + } catch (e) { + setError('Parameters must be valid JSON') + return + } + + addTool({ + name: name.trim(), + description: description.trim(), + parameters_schema: parsedParams, + enabled: true, + builtin: false, + character_id: null, + }) + + Logger.info(`Custom tool created: ${name.trim()}`) + router.back() + } + + return ( + + + + + + Lowercase letters, numbers, and underscores only + + + + + + + + JSON Schema defining the function's parameters. Leave empty for no + parameters. + + + + {error !== '' && {error}} + + + + ) +} + +export default AddTool + +const useStyles = () => { + const { color, spacing } = Theme.useTheme() + return StyleSheet.create({ + mainContainer: { + marginVertical: spacing.xl, + paddingVertical: spacing.xl, + paddingHorizontal: spacing.xl, + flex: 1, + }, + + hintText: { + marginTop: spacing.s, + color: color.text._400, + }, + + errorText: { + color: color.error._400, + marginTop: spacing.s, + }, + }) +} diff --git a/app/screens/ToolManagerScreen/ToolEditor.tsx b/app/screens/ToolManagerScreen/ToolEditor.tsx new file mode 100644 index 00000000..e9108b7d --- /dev/null +++ b/app/screens/ToolManagerScreen/ToolEditor.tsx @@ -0,0 +1,180 @@ +import ThemedButton from '@components/buttons/ThemedButton' +import ThemedTextInput from '@components/input/ThemedTextInput' +import FadeBackrop from '@components/views/FadeBackdrop' +import { ToolDefinitionType } from 'db/schema' +import { Logger } from '@lib/state/Logger' +import { ToolState } from '@lib/state/ToolState' +import { Theme } from '@lib/theme/ThemeManager' +import { useEffect, useState } from 'react' +import { Modal, ScrollView, StyleSheet, Text, View } from 'react-native' +import Animated, { FadeIn, SlideOutDown } from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +type ToolEditorProps = { + tool: ToolDefinitionType + show: boolean + close: () => void +} + +const ToolEditor: React.FC = ({ tool, show, close }) => { + const { color, spacing, fontSize } = Theme.useTheme() + const styles = useStyles() + const updateTool = ToolState.useToolStore((state) => state.updateTool) + + const [name, setName] = useState(tool.name) + const [description, setDescription] = useState(tool.description) + const [parametersJson, setParametersJson] = useState( + JSON.stringify(tool.parameters_schema, null, 2) + ) + const [error, setError] = useState('') + + useEffect(() => { + setName(tool.name) + setDescription(tool.description) + setParametersJson(JSON.stringify(tool.parameters_schema, null, 2)) + setError('') + }, [tool, show]) + + const handleSave = () => { + setError('') + + if (!name.trim()) { + setError('Name is required') + return + } + + if (!tool.builtin && !/^[a-z0-9_]+$/.test(name.trim())) { + setError('Name must be lowercase alphanumeric with underscores only') + return + } + + if (!description.trim()) { + setError('Description is required') + return + } + + let parsedParams: object + try { + parsedParams = JSON.parse(parametersJson.trim() || '{"type":"object","properties":{}}') + } catch (e) { + setError('Parameters must be valid JSON') + return + } + + const updates: Partial = { + description: description.trim(), + } + + if (!tool.builtin) { + updates.name = name.trim() + updates.parameters_schema = parsedParams + } + + updateTool(tool.id, updates) + Logger.info(`Tool updated: ${tool.name}`) + close() + } + + return ( + + + + + + + Edit Tool + + + + + {tool.builtin && ( + Built-in tool names cannot be changed + )} + + + + + + {tool.builtin && ( + + Built-in tool parameters cannot be changed + + )} + + + {error !== '' && {error}} + + + + + ) +} + +export default ToolEditor + +const useStyles = () => { + const insets = useSafeAreaInsets() + const { color, spacing, borderRadius } = Theme.useTheme() + return StyleSheet.create({ + mainContainer: { + marginVertical: spacing.xl, + paddingTop: spacing.xl2, + paddingBottom: insets.bottom, + paddingHorizontal: spacing.xl, + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + minHeight: '70%', + backgroundColor: color.neutral._100, + }, + + hintText: { + marginTop: spacing.s, + color: color.text._400, + }, + + errorText: { + color: color.error._400, + marginTop: spacing.s, + }, + }) +} diff --git a/app/screens/ToolManagerScreen/ToolItem.tsx b/app/screens/ToolManagerScreen/ToolItem.tsx new file mode 100644 index 00000000..0238f39d --- /dev/null +++ b/app/screens/ToolManagerScreen/ToolItem.tsx @@ -0,0 +1,153 @@ +import ThemedButton from '@components/buttons/ThemedButton' +import ThemedSwitch from '@components/input/ThemedSwitch' +import Alert from '@components/views/Alert' +import { ToolDefinitionType } from 'db/schema' +import { ToolState } from '@lib/state/ToolState' +import { Theme } from '@lib/theme/ThemeManager' +import { useState } from 'react' +import { StyleSheet, Text, View } from 'react-native' +import { useShallow } from 'zustand/react/shallow' + +import ToolEditor from './ToolEditor' + +type ToolItemProps = { + tool: ToolDefinitionType + index: number +} + +const ToolItem: React.FC = ({ tool, index }) => { + const { spacing, fontSize } = Theme.useTheme() + const styles = useStyles() + const [showEditor, setShowEditor] = useState(false) + + const { toggleTool, removeTool } = ToolState.useToolStore( + useShallow((state) => ({ + toggleTool: state.toggleTool, + removeTool: state.removeTool, + })) + ) + + const handleDelete = () => { + Alert.alert({ + title: 'Delete Tool', + description: `Are you sure you want to delete "${tool.name}"?`, + buttons: [ + { label: 'Cancel' }, + { + label: 'Delete Tool', + onPress: () => { + removeTool(tool.id) + }, + type: 'warning', + }, + ], + }) + } + + const sourceLabel = tool.builtin ? 'Built-in' : 'Custom' + + return ( + + setShowEditor(false)} /> + + + toggleTool(tool.id)} + /> + + + {tool.name} + + + {tool.description || 'No description'} + + {sourceLabel} + + + + {!tool.builtin && ( + + + setShowEditor(true)} + variant="tertiary" + iconName="edit" + iconSize={24} + buttonStyle={{ borderWidth: 0 }} + /> + + )} + + ) +} + +export default ToolItem + +const useStyles = () => { + const { color, spacing, borderWidth, fontSize } = Theme.useTheme() + return StyleSheet.create({ + container: { + borderColor: color.primary._500, + borderWidth: borderWidth.m, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderRadius: spacing.xl, + flex: 1, + paddingLeft: spacing.xl, + paddingRight: spacing.xl, + paddingVertical: spacing.xl, + }, + + containerInactive: { + borderColor: color.neutral._200, + borderWidth: borderWidth.m, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderRadius: spacing.xl, + flex: 1, + paddingLeft: spacing.xl, + paddingRight: spacing.xl, + paddingVertical: spacing.xl, + }, + + name: { + fontSize: fontSize.l, + color: color.text._100, + }, + + nameInactive: { + fontSize: fontSize.l, + color: color.text._400, + }, + + description: { + color: color.text._400, + fontSize: fontSize.s, + }, + + descriptionInactive: { + color: color.text._700, + fontSize: fontSize.s, + }, + + badge: { + color: color.text._500, + fontSize: fontSize.s, + marginTop: spacing.xs, + fontStyle: 'italic', + }, + }) +} diff --git a/app/screens/ToolManagerScreen/index.tsx b/app/screens/ToolManagerScreen/index.tsx new file mode 100644 index 00000000..3dbd2bc1 --- /dev/null +++ b/app/screens/ToolManagerScreen/index.tsx @@ -0,0 +1,71 @@ +import ThemedButton from '@components/buttons/ThemedButton' +import HeaderTitle from '@components/views/HeaderTitle' +import { Ionicons } from '@expo/vector-icons' +import { ToolState } from '@lib/state/ToolState' +import { Theme } from '@lib/theme/ThemeManager' +import { useRouter } from 'expo-router' +import { FlatList, Text, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useShallow } from 'zustand/react/shallow' + +import ToolItem from './ToolItem' + +const ToolManagerScreen = () => { + const { tools } = ToolState.useToolStore( + useShallow((state) => ({ + tools: state.tools, + })) + ) + const { color, spacing } = Theme.useTheme() + const router = useRouter() + + return ( + + + + {tools.length > 0 && ( + item.id.toString()} + renderItem={({ item, index }) => } + removeClippedSubviews={false} + showsVerticalScrollIndicator={false} + /> + )} + + {tools.length === 0 && ( + + + + No Tools Available + + + )} + + router.push('/screens/ToolManagerScreen/AddTool')} + label="Add Custom Tool" + /> + + ) +} + +export default ToolManagerScreen diff --git a/db/migrations/0018_organic_morbius.sql b/db/migrations/0018_organic_morbius.sql new file mode 100644 index 00000000..bba543ba --- /dev/null +++ b/db/migrations/0018_organic_morbius.sql @@ -0,0 +1,15 @@ +CREATE TABLE `tool_definitions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `description` text DEFAULT '' NOT NULL, + `parameters_schema` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `builtin` integer DEFAULT false NOT NULL, + `character_id` integer, + `created_at` integer, + FOREIGN KEY (`character_id`) REFERENCES `characters`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `chat_entries` ADD `role` text;--> statement-breakpoint +ALTER TABLE `chat_swipes` ADD `tool_calls` text;--> statement-breakpoint +ALTER TABLE `chat_swipes` ADD `tool_call_id` text; \ No newline at end of file diff --git a/db/migrations/meta/0018_snapshot.json b/db/migrations/meta/0018_snapshot.json new file mode 100644 index 00000000..67f5a9bf --- /dev/null +++ b/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,1273 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "661d691e-f468-48b5-87dd-b16443f4af4c", + "prevId": "826d2b22-cb3a-4894-bfe7-7ca6c070e94f", + "tables": { + "character_greetings": { + "name": "character_greetings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "character_id": { + "name": "character_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "greeting": { + "name": "greeting", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_greetings_character_id_characters_id_fk": { + "name": "character_greetings_character_id_characters_id_fk", + "tableFrom": "character_greetings", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "character_lorebooks": { + "name": "character_lorebooks", + "columns": { + "character_id": { + "name": "character_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lorebook_id": { + "name": "lorebook_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_lorebooks_character_id_characters_id_fk": { + "name": "character_lorebooks_character_id_characters_id_fk", + "tableFrom": "character_lorebooks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "character_lorebooks_lorebook_id_lorebooks_id_fk": { + "name": "character_lorebooks_lorebook_id_lorebooks_id_fk", + "tableFrom": "character_lorebooks", + "tableTo": "lorebooks", + "columnsFrom": [ + "lorebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "character_lorebooks_character_id_lorebook_id_pk": { + "columns": [ + "character_id", + "lorebook_id" + ], + "name": "character_lorebooks_character_id_lorebook_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "character_tags": { + "name": "character_tags", + "columns": { + "character_id": { + "name": "character_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_tags_character_id_characters_id_fk": { + "name": "character_tags_character_id_characters_id_fk", + "tableFrom": "character_tags", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "character_tags_tag_id_tags_id_fk": { + "name": "character_tags_tag_id_tags_id_fk", + "tableFrom": "character_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "character_tags_character_id_tag_id_pk": { + "columns": [ + "character_id", + "tag_id" + ], + "name": "character_tags_character_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "characters": { + "name": "characters", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'User'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "first_mes": { + "name": "first_mes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "mes_example": { + "name": "mes_example", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "creator_notes": { + "name": "creator_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "scenario": { + "name": "scenario", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "personality": { + "name": "personality", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_history_instructions": { + "name": "post_history_instructions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator": { + "name": "creator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "character_version": { + "name": "character_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image": { + "name": "background_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_attachment": { + "name": "chat_attachment", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_entry_id": { + "name": "chat_entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "chat_attachment_chat_entry_id_chat_entries_id_fk": { + "name": "chat_attachment_chat_entry_id_chat_entries_id_fk", + "tableFrom": "chat_attachment", + "tableTo": "chat_entries", + "columnsFrom": [ + "chat_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_entries": { + "name": "chat_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_user": { + "name": "is_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "swipe_id": { + "name": "swipe_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_entries_chat_id_chats_id_fk": { + "name": "chat_entries_chat_id_chats_id_fk", + "tableFrom": "chat_entries", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_swipes": { + "name": "chat_swipes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entry_id": { + "name": "entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "swipe": { + "name": "swipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "send_date": { + "name": "send_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gen_started": { + "name": "gen_started", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gen_finished": { + "name": "gen_finished", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timings": { + "name": "timings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_swipes_entry_id_chat_entries_id_fk": { + "name": "chat_swipes_entry_id_chat_entries_id_fk", + "tableFrom": "chat_swipes", + "tableTo": "chat_entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "create_date": { + "name": "create_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "character_id": { + "name": "character_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New Chat'" + }, + "scroll_offset": { + "name": "scroll_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "chats_character_id_characters_id_fk": { + "name": "chats_character_id_characters_id_fk", + "tableFrom": "chats", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instructs": { + "name": "instructs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "system_prefix": { + "name": "system_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "system_suffix": { + "name": "system_suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inpput_prefix": { + "name": "inpput_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_suffix": { + "name": "input_suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_suffix": { + "name": "output_suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_prefix": { + "name": "output_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stop_sequence": { + "name": "stop_sequence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activation_regex": { + "name": "activation_regex", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_alignment_message": { + "name": "user_alignment_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wrap": { + "name": "wrap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "macro": { + "name": "macro", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "names": { + "name": "names", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "names_force_groups": { + "name": "names_force_groups", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "examples": { + "name": "examples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "format_type": { + "name": "format_type", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_output_prefix": { + "name": "last_output_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "scenario": { + "name": "scenario", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "personality": { + "name": "personality", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hide_think_tags": { + "name": "hide_think_tags", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "use_common_stop": { + "name": "use_common_stop", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "send_images": { + "name": "send_images", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "send_audio": { + "name": "send_audio", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "send_documents": { + "name": "send_documents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_image_only": { + "name": "last_image_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "system_prompt_format": { + "name": "system_prompt_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{{system_prefix}}{{system_prompt}}\n{{character_desc}}\n{{personality}}\n{{scenario}}\n{{user_desc}}{{system_suffix}}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lorebook_entries": { + "name": "lorebook_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "lorebook_id": { + "name": "lorebook_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keys": { + "name": "keys", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enable": { + "name": "enable", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "insertion_order": { + "name": "insertion_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "case_sensitive": { + "name": "case_sensitive", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + } + }, + "indexes": {}, + "foreignKeys": { + "lorebook_entries_lorebook_id_lorebooks_id_fk": { + "name": "lorebook_entries_lorebook_id_lorebooks_id_fk", + "tableFrom": "lorebook_entries", + "tableTo": "lorebooks", + "columnsFrom": [ + "lorebook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lorebooks": { + "name": "lorebooks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scan_depth": { + "name": "scan_depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_budget": { + "name": "token_budget", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recursive_scanning": { + "name": "recursive_scanning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_data": { + "name": "model_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "params": { + "name": "params", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantization": { + "name": "quantization", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "create_date": { + "name": "create_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_data_file_unique": { + "name": "model_data_file_unique", + "columns": [ + "file" + ], + "isUnique": true + }, + "model_data_file_path_unique": { + "name": "model_data_file_path_unique", + "columns": [ + "file_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_mmproj_links": { + "name": "model_mmproj_links", + "columns": { + "model_id": { + "name": "model_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mmproj_id": { + "name": "mmproj_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "model_mmproj_links_model_id_model_data_id_fk": { + "name": "model_mmproj_links_model_id_model_data_id_fk", + "tableFrom": "model_mmproj_links", + "tableTo": "model_data", + "columnsFrom": [ + "model_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_mmproj_links_mmproj_id_model_data_id_fk": { + "name": "model_mmproj_links_mmproj_id_model_data_id_fk", + "tableFrom": "model_mmproj_links", + "tableTo": "model_data", + "columnsFrom": [ + "mmproj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_mmproj_links_model_id_mmproj_id_pk": { + "columns": [ + "model_id", + "mmproj_id" + ], + "name": "model_mmproj_links_model_id_mmproj_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_tag_unique": { + "name": "tags_tag_unique", + "columns": [ + "tag" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_definitions": { + "name": "tool_definitions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "parameters_schema": { + "name": "parameters_schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "builtin": { + "name": "builtin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "character_id": { + "name": "character_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tool_definitions_character_id_characters_id_fk": { + "name": "tool_definitions_character_id_characters_id_fk", + "tableFrom": "tool_definitions", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 7210a4bd..9c9d342b 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1755240790214, "tag": "0017_regular_lady_ursula", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1773201226528, + "tag": "0018_organic_morbius", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/migrations/migrations.js b/db/migrations/migrations.js index 5a5abf0d..cdf073e9 100644 --- a/db/migrations/migrations.js +++ b/db/migrations/migrations.js @@ -19,6 +19,7 @@ import m0014 from './0014_living_scarlet_spider.sql'; import m0015 from './0015_fat_red_hulk.sql'; import m0016 from './0016_violet_meteorite.sql'; import m0017 from './0017_regular_lady_ursula.sql'; +import m0018 from './0018_organic_morbius.sql'; export default { journal, @@ -40,7 +41,8 @@ m0013, m0014, m0015, m0016, -m0017 +m0017, +m0018 } } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index 11e06923..bad3d9c3 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -62,6 +62,7 @@ export const characterRelations = relations(characters, ({ many }) => ({ tags: many(characterTags), lorebooks: many(characterLorebooks), chats: many(chats), + tools: many(toolDefinitions), })) export const greetingsRelations = relations(characterGreetings, ({ one }) => ({ @@ -113,6 +114,8 @@ export const chatEntries = sqliteTable('chat_entries', { name: text('name').notNull(), order: integer('order').notNull(), swipe_id: integer('swipe_id', { mode: 'number' }).default(0).notNull(), + // addition: tool calling support + role: text('role', { enum: ['user', 'assistant', 'tool', 'system'] }), }) export const chatSwipes = sqliteTable('chat_swipes', { @@ -134,6 +137,9 @@ export const chatSwipes = sqliteTable('chat_swipes', { .notNull() .$defaultFn(() => new Date()), timings: text('timings', { mode: 'json' }).$type(), + // addition: tool calling support + tool_calls: text('tool_calls', { mode: 'json' }).$type(), + tool_call_id: text('tool_call_id'), }) export const chatsRelations = relations(chats, ({ many, one }) => ({ @@ -179,6 +185,28 @@ export const mediaAttachmentsRelations = relations(chatAttachments, ({ one }) => }), })) +// TOOL DEFINITIONS + +export const toolDefinitions = sqliteTable('tool_definitions', { + id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + description: text('description').notNull().default(''), + parameters_schema: text('parameters_schema', { mode: 'json' }).$type().notNull(), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + builtin: integer('builtin', { mode: 'boolean' }).notNull().default(false), + character_id: integer('character_id', { mode: 'number' }).references(() => characters.id, { + onDelete: 'cascade', + }), + created_at: integer('created_at', { mode: 'number' }).$defaultFn(() => Date.now()), +}) + +export const toolDefinitionsRelations = relations(toolDefinitions, ({ one }) => ({ + character: one(characters, { + fields: [toolDefinitions.character_id], + references: [characters.id], + }), +})) + // INSTRUCT const defaultSystemPrompt = @@ -372,3 +400,11 @@ export type CompletionTimings = { prompt_ms: number prompt_n: number } + +export type ToolCallData = { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +export type ToolDefinitionType = typeof toolDefinitions.$inferSelect diff --git a/lib/engine/API/APIBuilder.ts b/lib/engine/API/APIBuilder.ts index 01f389a1..09168d1c 100644 --- a/lib/engine/API/APIBuilder.ts +++ b/lib/engine/API/APIBuilder.ts @@ -1,5 +1,7 @@ import { AppSettings, CLAUDE_VERSION } from '@lib/constants/GlobalValues' import { SSEFetch } from '@lib/engine/SSEFetch' +import { ToolCallAccumulator } from '@lib/engine/Tools/ToolCallAccumulator' +import { AccumulatedToolCall, OpenAIToolDefinition } from '@lib/engine/Tools/ToolTypes' import { Logger } from '@lib/state/Logger' import { nativeApplicationVersion } from 'expo-application' @@ -14,6 +16,9 @@ export interface APIBuilderParams onEnd: (data: string) => void stopSequence: string[] stopGenerating: () => void + // Tool calling support + tools?: OpenAIToolDefinition[] + onToolCalls?: (toolCalls: AccumulatedToolCall[]) => void } export const buildAndSendRequest = async ({ @@ -33,6 +38,8 @@ export const buildAndSendRequest = async ({ messageLoader, maxLength, cache, + tools, + onToolCalls, }: APIBuilderParams) => { try { let payload: any = undefined @@ -64,6 +71,7 @@ export const buildAndSendRequest = async ({ instruct, prompt, stopSequence, + tools, }) if (!payload) { @@ -92,21 +100,44 @@ export const buildAndSendRequest = async ({ const replaceStrings = constructReplaceStrings(stopSequence) + // Use ToolCallAccumulator when tools are provided + const useToolAccumulator = tools && tools.length > 0 + const accumulator = useToolAccumulator ? new ToolCallAccumulator() : null + return sendFunc({ endpoint: apiValues.endpoint, payload: payload, onEvent: (event) => { try { - const data = getNestedValue( - typeof event === 'string' ? JSON.parse(event) : event, - apiConfig.request.responseParsePattern - ) - const text = data.replaceAll(replaceStrings, '') - - onData(text) + const parsed = typeof event === 'string' ? JSON.parse(event) : event + + if (accumulator) { + // Tool-aware parsing: extract both text and tool call chunks + const { text } = accumulator.processChunk(parsed) + if (text) { + const cleaned = text.replaceAll(replaceStrings, '') + onData(cleaned) + } + } else { + // Original text-only parsing + const data = getNestedValue( + parsed, + apiConfig.request.responseParsePattern + ) + if (data) { + const text = data.replaceAll(replaceStrings, '') + onData(text) + } + } } catch (e) {} }, - onEnd: onEnd, + onEnd: (data: string) => { + // Check if accumulator captured tool calls + if (accumulator && accumulator.isToolCall() && onToolCalls) { + onToolCalls(accumulator.getToolCalls()) + } + onEnd(data) + }, header: header, stopGenerating: stopGenerating, }) @@ -260,7 +291,7 @@ const readableStreamResponse = async (senderParams: SenderParams) => { } const constructReplaceStrings = (stopSequence: string[]) => { - const replace = RegExp( + return RegExp( stopSequence.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join(`|`), 'g' ) diff --git a/lib/engine/API/APIBuilder.types.ts b/lib/engine/API/APIBuilder.types.ts index d7570510..2da5f2ce 100644 --- a/lib/engine/API/APIBuilder.types.ts +++ b/lib/engine/API/APIBuilder.types.ts @@ -32,6 +32,7 @@ export interface APIFeatures { useKey: boolean useModel: boolean multipleModels: boolean + useTools?: boolean } export interface APIRequestFormat { diff --git a/lib/engine/API/ContextBuilder.ts b/lib/engine/API/ContextBuilder.ts index fde0aaed..ec3c10be 100644 --- a/lib/engine/API/ContextBuilder.ts +++ b/lib/engine/API/ContextBuilder.ts @@ -7,6 +7,7 @@ import { mmkv } from '@lib/storage/MMKV' import { readAsStringAsync } from 'expo-file-system' import { replaceMacros } from '@lib/state/Macros' +import { ToolCallData } from 'db/schema' import { APIConfiguration, APIValues } from './APIBuilder.types' import { Macro } from '@lib/utils/Macros' @@ -42,7 +43,12 @@ type ContentTypes = | { type: 'image_url'; image_url: { url: string } } | { type: 'input_audio'; input_audio: { data: string; format: string } } -export type Message = { role: string; [x: string]: ContentTypes[] | string } +export type Message = { + role: string + tool_call_id?: string + tool_calls?: ToolCallData[] + [x: string]: ContentTypes[] | string | ToolCallData[] | null | undefined +} export const buildContext = async (params: ContextBuilderParams) => { if (params.apiConfig.request.completionType.type === 'chatCompletions') { @@ -126,7 +132,42 @@ export const buildChatCompletionContext = async ({ index-- continue } - const role = message.is_user ? completionFeats.userRole : completionFeats.assistantRole + + // Determine role: tool messages use 'tool', otherwise use is_user to pick user/assistant + const role = + message.role === 'tool' + ? 'tool' + : message.is_user + ? completionFeats.userRole + : completionFeats.assistantRole + + // Handle tool result messages specially + if (message.role === 'tool' && swipe_data.tool_call_id) { + messageBuffer.push({ + role: 'tool', + tool_call_id: swipe_data.tool_call_id, + [completionFeats.contentName]: swipe_data.swipe, + }) + total_length += len + index-- + continue + } + + // Handle assistant messages with tool_calls + if ( + !message.is_user && + swipe_data.tool_calls && + swipe_data.tool_calls.length > 0 + ) { + messageBuffer.push({ + role: completionFeats.assistantRole, + [completionFeats.contentName]: swipe_data.swipe || null, + tool_calls: swipe_data.tool_calls, + }) + total_length += len + index-- + continue + } if (message.attachments.length > 0) { Logger.warn('Image output is incomplete') diff --git a/lib/engine/API/DefaultAPI.ts b/lib/engine/API/DefaultAPI.ts index 5c28e673..d49d35a3 100644 --- a/lib/engine/API/DefaultAPI.ts +++ b/lib/engine/API/DefaultAPI.ts @@ -23,6 +23,7 @@ export const defaultTemplates: APIConfiguration[] = [ useKey: true, useModel: true, multipleModels: false, + useTools: true, }, request: { @@ -443,6 +444,7 @@ export const defaultTemplates: APIConfiguration[] = [ usePrefill: false, useFirstMessage: false, multipleModels: false, + useTools: true, }, request: { @@ -582,6 +584,7 @@ export const defaultTemplates: APIConfiguration[] = [ useKey: true, useModel: true, multipleModels: false, + useTools: true, }, request: { @@ -650,6 +653,7 @@ export const defaultTemplates: APIConfiguration[] = [ useKey: true, useModel: true, multipleModels: false, + useTools: true, }, request: { @@ -718,6 +722,7 @@ export const defaultTemplates: APIConfiguration[] = [ useKey: true, useModel: true, multipleModels: false, + useTools: true, }, request: { diff --git a/lib/engine/API/RequestBuilder.ts b/lib/engine/API/RequestBuilder.ts index 9feb870f..9ac955ef 100644 --- a/lib/engine/API/RequestBuilder.ts +++ b/lib/engine/API/RequestBuilder.ts @@ -1,6 +1,7 @@ import { SamplerConfigData, SamplerID, Samplers } from '@lib/constants/SamplerData' import { InstructType } from '@lib/state/Instructs' import { SamplersManager } from '@lib/state/SamplerState' +import { OpenAIToolDefinition } from '@lib/engine/Tools/ToolTypes' import { APIConfiguration, APISampler, APIValues } from './APIBuilder.types' import { Message } from './ContextBuilder' @@ -12,6 +13,7 @@ export interface RequestBuilderParams { instruct: InstructType stopSequence: string[] prompt: string | Message[] + tools?: OpenAIToolDefinition[] } type SamplerField = { @@ -25,9 +27,10 @@ export const buildRequest = async ({ instruct, prompt, stopSequence, + tools, }: RequestBuilderParams) => { const samplerFields = getSamplerFields(apiConfig, apiValues, samplers) - const fields = await buildFields(apiConfig, apiValues, samplerFields, stopSequence, prompt) + const fields = await buildFields(apiConfig, apiValues, samplerFields, stopSequence, prompt, tools) switch (apiConfig.payload.type) { case 'openai': @@ -45,13 +48,17 @@ export const buildRequest = async ({ } } -const openAIRequest = async ({ payloadFields, model, stop, prompt }: Field) => { - return { +const openAIRequest = async ({ payloadFields, model, stop, prompt, tools }: Field) => { + const request: any = { ...payloadFields, ...model, ...stop, ...prompt, } + if (tools && tools.length > 0) { + request.tools = tools + } + return request } const ollamaRequest = async ({ payloadFields, model, stop, prompt }: Field) => { @@ -194,7 +201,8 @@ const buildFields = async ( values: APIValues, payloadFields: SamplerField, stopSeq: string[], - promptData: string | Message[] + promptData: string | Message[], + tools?: OpenAIToolDefinition[] ) => { // Model Data const model = config.features.useModel @@ -234,7 +242,7 @@ const buildFields = async ( const prompt = { [config.request.promptKey]: promptData } - return { payloadFields, model, stop, prompt, length } + return { payloadFields, model, stop, prompt, length, tools } } const getNestedValue = (obj: any, path: string) => { diff --git a/lib/engine/Inference.ts b/lib/engine/Inference.ts index 4a22d5f4..d8b40102 100644 --- a/lib/engine/Inference.ts +++ b/lib/engine/Inference.ts @@ -5,6 +5,7 @@ import BackgroundService from 'react-native-background-actions' import { AppSettings } from '@lib/constants/GlobalValues' import { Instructs } from '@lib/state/Instructs' import { SamplersManager } from '@lib/state/SamplerState' +import { ToolState } from '@lib/state/ToolState' import { useTTSStore } from '@lib/state/TTS' import { mmkv } from '@lib/storage/MMKV' import { useCallback } from 'react' @@ -15,6 +16,8 @@ import { APIConfiguration, APIValues } from './API/APIBuilder.types' import { APIManager } from './API/APIManagerState' import { localInference } from './LocalInference' import { Tokenizer } from './Tokenizer' +import { executeToolCalls } from './Tools/ToolExecutor' +import { AccumulatedToolCall } from './Tools/ToolTypes' export async function regenerateResponse(swipeId: number, regenCache: boolean = true) { const charName = Characters.useCharacterStore.getState().card?.name @@ -77,7 +80,18 @@ export async function generateResponse(swipeId: number) { if (appMode === 'local') { await BackgroundService.start(localInference, completionTaskOptions) } else { - await BackgroundService.start(chatInferenceStream, completionTaskOptions) + // Check if tools are enabled for the current API and character + const characterId = Characters.useCharacterStore.getState().card?.id + const tools = ToolState.useToolStore.getState().getToolsPayload(characterId) + const apiState = APIManager.useConnectionsStore.getState() + const apiValues = apiState.values[apiState.activeIndex] + const apiConfig = apiValues + ? apiState.getTemplates().find((t) => t.name === apiValues.configName) + : undefined + const useTools = tools.length > 0 && apiConfig?.features?.useTools + + const inferFn = useTools ? chatInferenceStreamWithTools : chatInferenceStream + await BackgroundService.start(inferFn, completionTaskOptions) } } // TODO: Use this @@ -130,6 +144,133 @@ async function chatInferenceStream() { }) } +const MAX_TOOL_ROUNDS = 10 + +async function chatInferenceStreamWithTools() { + const stop = () => Chats.useChatState.getState().stopGenerating() + let round = 0 + let abortedByUser = false + + while (round < MAX_TOOL_ROUNDS) { + round++ + Logger.info(`Tool calling round ${round}`) + + const fields = await obtainFields() + if (!fields) { + Logger.error('Chat Inference Failed') + stop() + return + } + + // Get enabled tools for current character + const characterId = Characters.useCharacterStore.getState().card?.id + const tools = ToolState.useToolStore.getState().getToolsPayload(characterId) + + let toolCallsReceived: AccumulatedToolCall[] = [] + + // No-op: readableStreamResponse calls stopGenerating on stream close, + // which would kill the loop after round 1 if we passed the real stop(). + // The loop manages its own lifecycle and calls stop() when actually done. + fields.stopGenerating = () => {} + fields.onData = (text) => { + Chats.useChatState.getState().insertBuffer(text) + useTTSStore.getState().insertBuffer(text) + } + + fields.tools = tools + fields.onToolCalls = (toolCalls: AccumulatedToolCall[]) => { + toolCallsReceived = toolCalls + } + + // Wait for this round to complete + await new Promise(async (resolve) => { + fields.onEnd = async () => { + resolve() + } + const abort = await buildAndSendRequest(fields) + useInference.getState().setAbort(() => { + Logger.debug('Running Abort') + if (abort) abort() + abortedByUser = true + resolve() // unblock on abort + }) + }) + + // Check if generation was stopped (user abort) + if (abortedByUser) { + Logger.info('Tool calling loop aborted by user') + stop() + return + } + + // No tool calls — normal text response, we're done + if (toolCallsReceived.length === 0) { + // Run auto-title generation if needed + const chat = Chats.useChatState.getState().data + if ( + mmkv.getBoolean(AppSettings.AutoGenerateTitle) && + chat && + chat.name === 'New Chat' + ) { + Logger.info('Generating Title') + titleGeneratorStream(chat.id) + } + stop() + return + } + + // Tool calls received! + Logger.info( + `Received ${toolCallsReceived.length} tool call(s): ${toolCallsReceived.map((tc) => tc.function.name).join(', ')}` + ) + + // 1. Save the assistant message with its buffer content + await Chats.useChatState.getState().updateFromBuffer() + + // 2. Persist tool_calls metadata on the current swipe + const messages = Chats.useChatState.getState().data?.messages + if (messages && messages.length > 0) { + await Chats.useChatState.getState().updateSwipeToolCalls( + messages.length - 1, + toolCallsReceived.map((tc) => ({ + id: tc.id, + type: tc.type, + function: tc.function, + })) + ) + } + + // 3. Execute all tool calls + const results = await executeToolCalls(toolCallsReceived) + for (const result of results) { + Logger.info( + `Tool ${result.name}: ${result.is_error ? 'ERROR' : 'OK'} - ${result.content.substring(0, 100)}` + ) + } + + // 4. Add tool result entries to the chat + for (const result of results) { + await Chats.useChatState.getState().addToolCallEntry( + result.tool_call_id, + result.name, + result.content + ) + } + + // 5. Add a new empty assistant entry for the next round + const charName = Characters.useCharacterStore.getState().card?.name ?? '' + await Chats.useChatState.getState().addEntry(charName, false, '') + Chats.useChatState.getState().setBuffer({ data: '' }) + + // Loop continues — next iteration will rebuild context including tool results + } + + if (round >= MAX_TOOL_ROUNDS) { + Logger.warn('Tool calling loop reached maximum rounds (' + MAX_TOOL_ROUNDS + ')') + } + stop() +} + const titleGeneratorStream = async (chatId: number) => { const fields = await obtainFields() if (!fields) { @@ -160,6 +301,7 @@ const titleGeneratorStream = async (chatId: number) => { chat_id: -1, name: '', is_user: true, + role: null, order: 0, swipe_id: 0, swipes: [ @@ -171,6 +313,8 @@ const titleGeneratorStream = async (chatId: number) => { gen_started: new Date(), gen_finished: new Date(), timings: null, + tool_calls: null, + tool_call_id: null, }, ], attachments: [], diff --git a/lib/engine/Tools/ToolCallAccumulator.ts b/lib/engine/Tools/ToolCallAccumulator.ts new file mode 100644 index 00000000..bea00413 --- /dev/null +++ b/lib/engine/Tools/ToolCallAccumulator.ts @@ -0,0 +1,74 @@ +import { AccumulatedToolCall, ToolCallChunk } from './ToolTypes' + +export class ToolCallAccumulator { + private toolCalls: Map = new Map() + private finishReason: string | null = null + + /** + * Process a single parsed SSE chunk from an OpenAI-compatible streaming response. + * Returns extracted text content (if any). + */ + processChunk(parsed: any): { text: string | null } { + const choice = parsed?.choices?.[0] + if (!choice) return { text: null } + + if (choice.finish_reason) { + this.finishReason = choice.finish_reason + } + + const delta = choice.delta + if (!delta) return { text: null } + + const text = delta.content ?? null + + if (delta.tool_calls && Array.isArray(delta.tool_calls)) { + for (const chunk of delta.tool_calls) { + this.accumulateToolCallChunk(chunk as ToolCallChunk) + } + } + + return { text } + } + + private accumulateToolCallChunk(chunk: ToolCallChunk): void { + const existing = this.toolCalls.get(chunk.index) + if (!existing) { + this.toolCalls.set(chunk.index, { + id: chunk.id ?? '', + type: 'function', + function: { + name: chunk.function?.name ?? '', + arguments: chunk.function?.arguments ?? '', + }, + }) + } else { + if (chunk.id) existing.id = chunk.id + if (chunk.function?.name) existing.function.name += chunk.function.name + if (chunk.function?.arguments) + existing.function.arguments += chunk.function.arguments + } + } + + /** + * Returns true if the stream ended with tool calls. + * Checks both finish_reason and presence of accumulated tool calls, + * since some APIs may use 'stop' as finish_reason even for tool calls. + */ + isToolCall(): boolean { + if (this.toolCalls.size === 0) return false + return this.finishReason === 'tool_calls' || this.finishReason === 'stop' + } + + getToolCalls(): AccumulatedToolCall[] { + return Array.from(this.toolCalls.values()) + } + + getFinishReason(): string | null { + return this.finishReason + } + + reset(): void { + this.toolCalls.clear() + this.finishReason = null + } +} diff --git a/lib/engine/Tools/ToolExecutor.ts b/lib/engine/Tools/ToolExecutor.ts new file mode 100644 index 00000000..574a06f2 --- /dev/null +++ b/lib/engine/Tools/ToolExecutor.ts @@ -0,0 +1,48 @@ +import { Logger } from '@lib/state/Logger' + +import { AccumulatedToolCall, ToolExecutionResult } from './ToolTypes' + +export type ToolHandler = (args: Record) => Promise + +const builtinTools: Map = new Map() + +export function registerBuiltinTool(name: string, handler: ToolHandler): void { + builtinTools.set(name, handler) +} + +export async function executeTool(toolCall: AccumulatedToolCall): Promise { + const handler = builtinTools.get(toolCall.function.name) + if (!handler) { + Logger.warn(`Unknown tool: "${toolCall.function.name}"`) + return { + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: `Error: Unknown tool "${toolCall.function.name}"`, + is_error: true, + } + } + try { + const args = JSON.parse(toolCall.function.arguments) + const result = await handler(args) + return { + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: result, + is_error: false, + } + } catch (e: any) { + Logger.error(`Tool execution error (${toolCall.function.name}): ${e}`) + return { + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: `Error executing tool: ${e?.message ?? e}`, + is_error: true, + } + } +} + +export async function executeToolCalls( + toolCalls: AccumulatedToolCall[] +): Promise { + return Promise.all(toolCalls.map(executeTool)) +} diff --git a/lib/engine/Tools/ToolTypes.ts b/lib/engine/Tools/ToolTypes.ts new file mode 100644 index 00000000..3cb8fda1 --- /dev/null +++ b/lib/engine/Tools/ToolTypes.ts @@ -0,0 +1,33 @@ +export type OpenAIToolDefinition = { + type: 'function' + function: { + name: string + description: string + parameters: object + } +} + +export type ToolCallChunk = { + index: number + id?: string + function?: { + name?: string + arguments?: string + } +} + +export type AccumulatedToolCall = { + id: string + type: 'function' + function: { + name: string + arguments: string + } +} + +export type ToolExecutionResult = { + tool_call_id: string + name: string + content: string + is_error: boolean +} diff --git a/lib/engine/Tools/builtins/index.ts b/lib/engine/Tools/builtins/index.ts new file mode 100644 index 00000000..6f98ba8d --- /dev/null +++ b/lib/engine/Tools/builtins/index.ts @@ -0,0 +1,153 @@ +import { registerBuiltinTool } from '../ToolExecutor' +import { OpenAIToolDefinition } from '../ToolTypes' + +// Built-in tool definitions in OpenAI format +export const BUILTIN_TOOL_DEFINITIONS: OpenAIToolDefinition[] = [ + { + type: 'function', + function: { + name: 'get_current_datetime', + description: 'Get the current date and time in ISO format', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'calculate', + description: + 'Evaluate a mathematical expression. Supports basic arithmetic: +, -, *, /, parentheses, and ** for exponentiation.', + parameters: { + type: 'object', + properties: { + expression: { + type: 'string', + description: + 'The mathematical expression to evaluate, e.g. "2 + 3 * 4" or "(10 - 2) / 4"', + }, + }, + required: ['expression'], + }, + }, + }, +] + +// Safe math expression evaluator (no eval) +function safeCalculate(expression: string): number { + const tokens = tokenize(expression) + const result = parseExpression(tokens, { pos: 0 }) + if (tokens.length > 0) throw new Error('Unexpected token: ' + tokens[0]) + return result +} + +type TokenRef = { pos: number } + +function tokenize(expr: string): string[] { + const tokens: string[] = [] + let i = 0 + while (i < expr.length) { + if (/\s/.test(expr[i])) { + i++ + continue + } + if ('+-*/()'.includes(expr[i])) { + // Check for ** operator + if (expr[i] === '*' && expr[i + 1] === '*') { + tokens.push('**') + i += 2 + } else { + tokens.push(expr[i]) + i++ + } + continue + } + if (/[0-9.]/.test(expr[i])) { + let num = '' + while (i < expr.length && /[0-9.]/.test(expr[i])) { + num += expr[i] + i++ + } + tokens.push(num) + continue + } + throw new Error('Invalid character: ' + expr[i]) + } + return tokens +} + +function parseExpression(tokens: string[], ref: TokenRef): number { + let left = parseTerm(tokens, ref) + while (tokens[0] === '+' || tokens[0] === '-') { + const op = tokens.shift()! + const right = parseTerm(tokens, ref) + left = op === '+' ? left + right : left - right + } + return left +} + +function parseTerm(tokens: string[], ref: TokenRef): number { + let left = parsePower(tokens, ref) + while (tokens[0] === '*' || tokens[0] === '/') { + const op = tokens.shift()! + const right = parsePower(tokens, ref) + left = op === '*' ? left * right : left / right + } + return left +} + +function parsePower(tokens: string[], ref: TokenRef): number { + let base = parseUnary(tokens, ref) + while (tokens[0] === '**') { + tokens.shift() + const exp = parseUnary(tokens, ref) + base = Math.pow(base, exp) + } + return base +} + +function parseUnary(tokens: string[], ref: TokenRef): number { + if (tokens[0] === '-') { + tokens.shift() + return -parsePrimary(tokens, ref) + } + if (tokens[0] === '+') { + tokens.shift() + } + return parsePrimary(tokens, ref) +} + +function parsePrimary(tokens: string[], ref: TokenRef): number { + const first = tokens[0] + if (first === '(') { + tokens.shift() // consume '(' + const result = parseExpression(tokens, ref) + if (tokens[0] !== ')') throw new Error('Expected )') + tokens.shift() // consume ')' + return result + } + const token = tokens.shift() + if (token === undefined) throw new Error('Unexpected end of expression') + const num = parseFloat(token) + if (isNaN(num)) throw new Error('Expected number, got: ' + token) + return num +} + +// Register built-in tools +export function registerBuiltinTools(): void { + registerBuiltinTool('get_current_datetime', async () => { + return new Date().toISOString() + }) + + registerBuiltinTool('calculate', async (args) => { + const { expression } = args + if (typeof expression !== 'string') { + throw new Error('expression must be a string') + } + const result = safeCalculate(expression) + return String(result) + }) +} diff --git a/lib/state/Chat.ts b/lib/state/Chat.ts index 413d3428..b3eceb60 100644 --- a/lib/state/Chat.ts +++ b/lib/state/Chat.ts @@ -13,6 +13,7 @@ import { chatSwipes, ChatType, CompletionTimings, + ToolCallData, } from 'db/schema' import { and, count, desc, eq, getTableColumns, like, sql } from 'drizzle-orm' import { randomUUID } from 'expo-crypto' @@ -113,6 +114,14 @@ export interface ChatState { ) => Promise stopGenerating: () => void startGenerating: (swipeId: number) => void + + // tool calling + addToolCallEntry: ( + toolCallId: string, + toolName: string, + result: string + ) => Promise + updateSwipeToolCalls: (index: number, toolCalls: ToolCallData[]) => Promise } type InferenceStateType = { @@ -130,7 +139,11 @@ type OutputBuffer = { error?: string } -type ChatSwipeUpdated = Pick & Partial> +type ChatSwipeUpdated = Pick & + Partial> & { + tool_calls?: ToolCallData[] + tool_call_id?: string + } // TODO: Functionalize and move elsewhere export const sendGenerateCompleteNotification = async () => { const showMessage = mmkv.getBoolean(AppSettings.ShowNotificationText) @@ -521,6 +534,53 @@ export namespace Chats { }) db.mutate.renameChat(chatId, name) }, + + // Tool calling methods + addToolCallEntry: async ( + toolCallId: string, + toolName: string, + result: string + ) => { + const messages = get().data?.messages + const chatId = get().data?.id + if (!messages || !chatId) return + const order = messages.length > 0 ? messages[messages.length - 1].order + 1 : 0 + const entry = await db.mutate.createEntry( + chatId, + toolName, + false, + order, + result, + [], + 'tool', + toolCallId + ) + if (entry) messages.push(entry) + set((state) => ({ + ...state, + data: state?.data ? { ...state.data, messages: [...messages] } : state.data, + })) + return entry?.swipes[0].id + }, + + updateSwipeToolCalls: async (index: number, toolCalls: ToolCallData[]) => { + const messages = get().data?.messages + if (!messages) return + const message = messages[index] + if (!message) return + const swipe = message.swipes[message.swipe_id] + if (!swipe) return + swipe.tool_calls = toolCalls + await db.mutate.updateChatSwipe({ + id: swipe.id, + swipe: swipe.swipe, + tool_calls: toolCalls, + }) + set((state) => ({ + ...state, + data: state?.data ? { ...state.data, messages: [...messages] } : state.data, + })) + }, })) export namespace db { @@ -691,7 +751,9 @@ export namespace Chats { isUser: boolean, order: number, message: string, - attachments: string[] = [] + attachments: string[] = [], + role?: 'user' | 'assistant' | 'tool' | 'system', + toolCallId?: string ) => { const [{ entryId }, ...__] = await database .insert(chatEntries) @@ -700,11 +762,16 @@ export namespace Chats { name: name, is_user: isUser, order: order, + ...(role ? { role } : {}), }) .returning({ entryId: chatEntries.id }) await database .insert(chatSwipes) - .values({ swipe: replaceMacros(message), entry_id: entryId }) + .values({ + swipe: replaceMacros(message), + entry_id: entryId, + ...(toolCallId ? { tool_call_id: toolCallId } : {}), + }) await Promise.all( attachments.map(async (uri) => { @@ -944,6 +1011,7 @@ export namespace Chats { chat_id: -1, name: '', is_user: false, + role: null, order: -1, swipe_id: 0, swipes: [ @@ -955,6 +1023,8 @@ export namespace Chats { gen_started: new Date(), gen_finished: new Date(), timings: null, + tool_calls: null, + tool_call_id: null, }, ], attachments: [], diff --git a/lib/state/ToolState.ts b/lib/state/ToolState.ts new file mode 100644 index 00000000..7b9c84c0 --- /dev/null +++ b/lib/state/ToolState.ts @@ -0,0 +1,138 @@ +import { create } from 'zustand' + +import { db as database } from '@db' +import { toolDefinitions, ToolDefinitionType } from 'db/schema' +import { Logger } from '@lib/state/Logger' +import { OpenAIToolDefinition } from '@lib/engine/Tools/ToolTypes' +import { BUILTIN_TOOL_DEFINITIONS } from '@lib/engine/Tools/builtins' +import { eq } from 'drizzle-orm' + +type ToolStateProps = { + tools: ToolDefinitionType[] + loadTools: () => Promise + addTool: (tool: Omit) => Promise + removeTool: (id: number) => Promise + updateTool: (id: number, updates: Partial) => Promise + toggleTool: (id: number) => Promise + getToolsForCharacter: (characterId?: number) => ToolDefinitionType[] + getToolsPayload: (characterId?: number) => OpenAIToolDefinition[] +} + +export namespace ToolState { + /** + * Seeds built-in tool definitions into the DB if they don't exist yet, + * then loads all tools into the store. + */ + export const initializeTools = async () => { + try { + const existing = await database.query.toolDefinitions.findMany() + const existingNames = new Set(existing.map((t) => t.name)) + + for (const def of BUILTIN_TOOL_DEFINITIONS) { + if (!existingNames.has(def.function.name)) { + await database.insert(toolDefinitions).values({ + name: def.function.name, + description: def.function.description, + parameters_schema: def.function.parameters, + enabled: true, + builtin: true, + character_id: null, + }) + Logger.info(`Seeded built-in tool: ${def.function.name}`) + } + } + + await useToolStore.getState().loadTools() + Logger.info( + `Tool definitions loaded: ${useToolStore.getState().tools.length} tool(s)` + ) + } catch (e) { + Logger.error('Failed to initialize tools: ' + e) + } + } + + export const useToolStore = create()((set, get) => ({ + tools: [], + + loadTools: async () => { + try { + const allTools = await database.query.toolDefinitions.findMany() + set({ tools: allTools }) + } catch (e) { + Logger.error('Failed to load tool definitions: ' + e) + } + }, + + addTool: async (tool) => { + try { + await database.insert(toolDefinitions).values(tool) + await get().loadTools() + } catch (e) { + Logger.error('Failed to add tool: ' + e) + } + }, + + removeTool: async (id) => { + try { + await database.delete(toolDefinitions).where(eq(toolDefinitions.id, id)) + await get().loadTools() + } catch (e) { + Logger.error('Failed to remove tool: ' + e) + } + }, + + updateTool: async (id, updates) => { + try { + await database + .update(toolDefinitions) + .set(updates) + .where(eq(toolDefinitions.id, id)) + await get().loadTools() + } catch (e) { + Logger.error('Failed to update tool: ' + e) + } + }, + + toggleTool: async (id) => { + const tool = get().tools.find((t) => t.id === id) + if (!tool) return + await get().updateTool(id, { enabled: !tool.enabled }) + }, + + getToolsForCharacter: (characterId?: number) => { + return get().tools.filter( + (t) => + t.enabled && + (t.character_id === null || t.character_id === characterId) + ) + }, + + getToolsPayload: (characterId?: number) => { + const enabledTools = get().getToolsForCharacter(characterId) + + // Merge built-in definitions with DB-stored custom tools + const payload: OpenAIToolDefinition[] = [] + + for (const tool of enabledTools) { + if (tool.builtin) { + // Find matching built-in definition + const builtin = BUILTIN_TOOL_DEFINITIONS.find( + (b) => b.function.name === tool.name + ) + if (builtin) payload.push(builtin) + } else { + payload.push({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters_schema, + }, + }) + } + } + + return payload + }, + })) +} diff --git a/lib/utils/Startup.ts b/lib/utils/Startup.ts index c0029d4e..b5bc25a4 100644 --- a/lib/utils/Startup.ts +++ b/lib/utils/Startup.ts @@ -1,5 +1,7 @@ import { Model } from '@lib/engine/Local/Model' +import { registerBuiltinTools } from '@lib/engine/Tools/builtins' import { Tokenizer } from '@lib/engine/Tokenizer' +import { ToolState } from '@lib/state/ToolState' import { setupNotifications } from '@lib/notifications/Notifications' import { useAppModeStore } from '@lib/state/AppMode' import { Instructs } from '@lib/state/Instructs' @@ -257,5 +259,9 @@ export const startupApp = () => { const backgroundColor = Theme.useColorState.getState().color.neutral._100 setUIBackgroundColor(backgroundColor) + // Register built-in tool handlers and seed DB definitions + registerBuiltinTools() + ToolState.initializeTools() + Logger.info('Resetting state values for startup.') }