Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions apps/nlp/app/llm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Button } from '../../components/Button';
import { getImage, skImageToBuffer } from '../../utils';
import {
LLM_MODELS,
parseLfm3ToolCalls,
parseMiniCpmToolCalls,
type LLMModelConfig,
type LLMBackend,
Expand All @@ -49,6 +50,13 @@ function formatStats(stats: llm.LLMGenerationStats): string {
);
}

// With thinking off, LFM 3 can still write a short draft closed by </think>
// before its answer; show only the answer.
function stripDraft(text: string): string {
const end = text.lastIndexOf('</think>');
return end === -1 ? text : text.slice(end + '</think>'.length).trimStart();
}

function LLMContent() {
const insets = useSafeAreaInsets();
const [selectedBackend, setSelectedBackend] = useState<LLMBackend>('xnnpack');
Expand Down Expand Up @@ -103,10 +111,15 @@ function LLMContent() {
const thinkingRef = useRef(enableThinking);
const supportsThinkingSwitch = activeModel.supportsThinkingSwitch !== false;
const toolOpts = useMemo(() => {
if (activeModel.toolOpts?.parseToolCalls !== parseMiniCpmToolCalls) return activeModel.toolOpts;
// Parsers whose output layout depends on the thinking switch.
const opts = activeModel.toolOpts;
const thinkingAware = [parseMiniCpmToolCalls, parseLfm3ToolCalls].find(
(p) => p === opts?.parseToolCalls
);
if (!opts || !thinkingAware) return opts;
return {
...activeModel.toolOpts,
parseToolCalls: (text: string) => parseMiniCpmToolCalls(text, thinkingRef.current),
...opts,
parseToolCalls: (text: string) => thinkingAware(text, thinkingRef.current),
};
}, [activeModel]);

Expand Down Expand Up @@ -303,6 +316,7 @@ function LLMContent() {
payload = textMessage;
}

const thinkingOff = supportsThinkingSwitch && !enableThinking;
const result = await sendMessage(
payload,
(token) => {
Expand All @@ -319,7 +333,8 @@ function LLMContent() {
)
.map((m) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : '',
content:
typeof m.content !== 'string' ? '' : thinkingOff ? stripDraft(m.content) : m.content,
stats: m.role === 'assistant' ? result.stats[assistantIdx++] : undefined,
toolCalls: m.role === 'assistant' ? m.toolCalls : undefined,
}));
Expand Down Expand Up @@ -533,7 +548,11 @@ function LLMContent() {
{streamingResponse !== null && (
<View style={styles.turn}>
<View style={[styles.bubble, styles.assistantBubble]}>
<Text style={styles.assistantText}>{streamingResponse || '…'}</Text>
<Text style={styles.assistantText}>
{(supportsThinkingSwitch && !enableThinking
? stripDraft(streamingResponse)
: streamingResponse) || '…'}
</Text>
</View>
</View>
)}
Expand Down
128 changes: 128 additions & 0 deletions apps/nlp/constants/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,48 @@ export function parseLfmToolCalls(text: string): llm.ToolParserResult | undefine
return { toolCalls, textContent: textContent || undefined };
}

export function parseLfm3ToolCalls(
text: string,
enableThinking = true
): llm.ToolParserResult | undefined {
// With thinking on, the generation prompt opens <think>: only parse the
// final answer. With it off, the prompt already closes it.
const thinkingEnd = text.lastIndexOf('</think>');
if (thinkingEnd === -1 && (enableThinking || text.includes('<think>'))) return undefined;
const answer = thinkingEnd === -1 ? text : text.slice(thinkingEnd + '</think>'.length);
// <tool_call>\n<function=NAME>\n<parameter=KEY>\nVALUE\n</parameter>\n</function>\n</tool_call>
const callRegex = /<tool_call>\s*<function=([^>\n]+)>([\s\S]*?)<\/function>\s*<\/tool_call>/g;
const toolCalls: llm.ToolCall[] = [];

for (const match of answer.matchAll(callRegex)) {
const name = match[1]!.trim();
const tool = TOOLS.find((candidate) => candidate.function.name === name);
if (!tool) return undefined;
const params = tool.function.parameters;
const paramRegex = /<parameter=([^>\n]+)>\n?([\s\S]*?)\n?<\/parameter>/g;
const args: Record<string, unknown> = {};
try {
for (const param of match[2]!.matchAll(paramRegex)) {
const key = param[1]!.trim();
const schema = params?.properties?.[key] as { type?: string } | undefined;
if (!schema || Object.prototype.hasOwnProperty.call(args, key)) return undefined;
// The template renders strings verbatim and everything else as JSON.
args[key] = schema.type === 'string' ? param[2]! : JSON.parse(param[2]!);
}
} catch {
return undefined;
}
toolCalls.push({ type: 'function', function: { name, arguments: args } });
}

if (toolCalls.length === 0) return undefined;
const textContent = answer
.replace(callRegex, '')
.replace(/<\|im_end\|>/g, '')
.trim();
return { toolCalls, textContent: textContent || undefined };
}

function decodeMiniCpmArgument(value: string): string {
const trimmed = value.trim();
if (trimmed.startsWith('<![CDATA[') && trimmed.endsWith(']]>')) {
Expand Down Expand Up @@ -609,11 +651,19 @@ export const BIELIK_V3_GENERATION_CONFIG: llm.LLMGenerationConfig = {

export const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant. Your name is HAL-9000';

// LFM 3 Audio model card prompt for transcription.
export const LFM3_AUDIO_SYSTEM_PROMPT = 'Perform ASR in English.';

export const LFM_TOOL_OPTS: LLMToolOpts = {
tools: TOOLS,
parseToolCalls: parseLfmToolCalls,
};

export const LFM3_TOOL_OPTS: LLMToolOpts = {
tools: TOOLS,
parseToolCalls: parseLfm3ToolCalls,
};

export const MINICPM5_TOOL_OPTS: LLMToolOpts = {
tools: TOOLS,
parseToolCalls: parseMiniCpmToolCalls,
Expand All @@ -639,6 +689,18 @@ export const LLAMA_TOOL_OPTS: LLMToolOpts = {
parseToolCalls: parseLlamaToolCalls,
};

/**
* LFM3 sampling defaults from the upstream generation_config.json.
* @see https://huggingface.co/LiquidAI/LFM3-1.7B/raw/main/generation_config.json
*/
export const LFM3_GENERATION_CONFIG: llm.LLMGenerationConfig = {
temperature: 0.1,
topP: 1,
topK: 50,
repetitionPenalty: 1.1,
maxNewTokens: 512,
};

// --- Model Config Type ---

export type LLMBackend = 'xnnpack' | 'mlx' | 'vulkan';
Expand Down Expand Up @@ -680,6 +742,72 @@ export const LLM_MODELS: LLMModelConfig[] = [
iosOnly: true,
},

// LFM 3 family
{
id: 'lfm-3-1.7b-xnnpack',
name: 'LFM 3 1.7B',
backend: 'xnnpack',
model: models.llm.LFM3_1_7B.XNNPACK_8DA4W,
generationConfig: LFM3_GENERATION_CONFIG,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
stopRegex: undefined,
toolOpts: LFM3_TOOL_OPTS,
},
{
id: 'lfm-3-1.7b-mlx',
name: 'LFM 3 1.7B',
backend: 'mlx',
model: models.llm.LFM3_1_7B.MLX_INT4,
generationConfig: LFM3_GENERATION_CONFIG,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
stopRegex: undefined,
toolOpts: LFM3_TOOL_OPTS,
iosOnly: true,
},
{
id: 'lfm-3-vl-2b-xnnpack',
name: 'LFM 3 VL 2B',
backend: 'xnnpack',
model: models.llm.LFM3_VL_2B.XNNPACK_8DA4W,
generationConfig: LFM3_GENERATION_CONFIG,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
stopRegex: undefined,
toolOpts: undefined,
},
{
id: 'lfm-3-vl-2b-mlx',
name: 'LFM 3 VL 2B',
backend: 'mlx',
model: models.llm.LFM3_VL_2B.MLX_INT4,
generationConfig: LFM3_GENERATION_CONFIG,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
stopRegex: undefined,
toolOpts: undefined,
iosOnly: true,
},

{
id: 'lfm-3-audio-2b-xnnpack',
name: 'LFM 3 Audio 2B',
backend: 'xnnpack',
model: models.llm.LFM3_AUDIO_2B.XNNPACK_8DA4W,
generationConfig: LFM3_GENERATION_CONFIG,
systemPrompt: LFM3_AUDIO_SYSTEM_PROMPT,
stopRegex: undefined,
toolOpts: undefined,
},
{
id: 'lfm-3-audio-2b-mlx',
name: 'LFM 3 Audio 2B',
backend: 'mlx',
model: models.llm.LFM3_AUDIO_2B.MLX_INT4,
generationConfig: LFM3_GENERATION_CONFIG,
systemPrompt: LFM3_AUDIO_SYSTEM_PROMPT,
stopRegex: undefined,
toolOpts: undefined,
iosOnly: true,
},

// LFM 2.5 family
{
id: 'lfm-2.5-2.6b-xnnpack',
Expand Down
Loading
Loading