From 493350cadabe2508ce007b0612e36382e099910a Mon Sep 17 00:00:00 2001 From: wwanarif Date: Thu, 4 Dec 2025 11:59:50 +0800 Subject: [PATCH 1/9] added test case 005 for V1.5 Signed-off-by: wwanarif --- .../005_test_funetuning_rerank.spec.ts | 64 +++++++++++++++++++ tests/test-files/toy_finetune_data.jsonl | 10 +++ 2 files changed, 74 insertions(+) create mode 100644 tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts create mode 100644 tests/test-files/toy_finetune_data.jsonl diff --git a/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts b/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts new file mode 100644 index 0000000..3e2694e --- /dev/null +++ b/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import { waitForStatusText } from '../utils'; +import path from 'path'; + +const trainDataset = path.resolve(__dirname, '../../test-files/toy_finetune_data.jsonl'); + +async function setupResponseListener(page, apiResponse) { + page.on('response', async (response) => { + if (response.url().includes('/v1/app-backend') && response.request().method() === 'POST') { + const contentType = response.headers()['content-type']; + if (contentType.includes('text/event-stream')) { + const responseBody = await response.text(); + // Parse SSE stream + const events = responseBody.split('\n\n'); + for (const event of events) { + const lines = event.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const cleanedData = line.slice(6, -1).trim(); // Remove 'data: ' prefix + apiResponse.value += cleanedData + " "; + } + } + } + } else { + console.error('Response is not SSE'); + } + } + }); +} + +test('005_test_funetuning_rerank', async ({ browser, baseURL }) => { + test.setTimeout(1200000); + let apiResponse = { value: '' }; + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + recordVideo: { + dir: './videos/', + size: { width: 1280, height: 720 } + } + }); + const page = await context.newPage(); + const IDC_URL = baseURL || "" + await page.goto(IDC_URL); + await page.getByLabel('Username or email').fill('test_automation@gmail.com'); + await page.getByLabel('Password', { exact: true }).click(); + await page.getByLabel('Password', { exact: true }).fill('test'); + await page.getByRole('button', { name: 'Sign In' }).click(); + await page.getByRole('button', { name: 'Fine-tuning' }).click(); + await page.getByRole('button', { name: 'Create New Job' }).click(); + await page.getByRole('combobox', { name: 'Base Model' }).click(); + await page.getByRole('option', { name: 'BAAI/bge-reranker-large' }).click(); + await page.getByText('Instruction Tuning').click(); + await page.getByRole('option', { name: 'Rerank' }).click(); + let fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose File' }).click(); + let fileChooser = await fileChooserPromise; + await fileChooser.setFiles(trainDataset); + await page.waitForTimeout(5000); + await page.getByRole('button', { name: 'Create Job' }).click(); + await page.waitForTimeout(20000); + await expect(page.getByRole('cell', { name: 'running' })).toHaveText('running'); + await expect(page.locator('div').filter({ hasText: 'Fine-tuning JobsCreate New' }).nth(3)).toContainText('rerank'); + await waitForStatusText(page, 'td.MuiTableCell-root div.MuiChip-root', 'succeeded', 20, 60000); +}); \ No newline at end of file diff --git a/tests/test-files/toy_finetune_data.jsonl b/tests/test-files/toy_finetune_data.jsonl new file mode 100644 index 0000000..51cad7d --- /dev/null +++ b/tests/test-files/toy_finetune_data.jsonl @@ -0,0 +1,10 @@ +{"query": "Five women walk along a beach wearing flip-flops.", "pos": ["Some women with flip-flops on, are walking along the beach"], "neg": ["The 4 women are sitting on the beach.", "There was a reform in 1996.", "She's not going to court to clear her record.", "The man is talking about hawaii.", "A woman is standing outside.", "The battle was over. ", "A group of people plays volleyball."]} +{"query": "A woman standing on a high cliff on one leg looking over a river.", "pos": ["A woman is standing on a cliff."], "neg": ["A woman sits on a chair.", "George Bush told the Republicans there was no way he would let them even consider this foolish idea, against his top advisors advice.", "The family was falling apart.", "no one showed up to the meeting", "A boy is sitting outside playing in the sand.", "Ended as soon as I received the wire.", "A child is reading in her bedroom."]} +{"query": "Two woman are playing instruments; one a clarinet, the other a violin.", "pos": ["Some people are playing a tune."], "neg": ["Two women are playing a guitar and drums.", "A man is skiing down a mountain.", "The fatal dose was not taken when the murderer thought it would be.", "Person on bike", "The girl is standing, leaning against the archway.", "A group of women watch soap operas.", "No matter how old people get they never forget. "]} +{"query": "A girl with a blue tank top sitting watching three dogs.", "pos": ["A girl is wearing blue."], "neg": ["A girl is with three cats.", "The people are watching a funeral procession.", "The child is wearing black.", "Financing is an issue for us in public schools.", "Kids at a pool.", "It is calming to be assaulted.", "I face a serious problem at eighteen years old. "]} +{"query": "A yellow dog running along a forest path.", "pos": ["a dog is running"], "neg": ["a cat is running", "Steele did not keep her original story.", "The rule discourages people to pay their child support.", "A man in a vest sits in a car.", "Person in black clothing, with white bandanna and sunglasses waits at a bus stop.", "Neither the Globe or Mail had comments on the current state of Canada's road system. ", "The Spring Creek facility is old and outdated."]} +{"query": "It sets out essential activities in each phase along with critical factors related to those activities.", "pos": ["Critical factors for essential activities are set out."], "neg": ["It lays out critical activities but makes no provision for critical factors related to those activities.", "People are assembled in protest.", "The state would prefer for you to do that.", "A girl sits beside a boy.", "Two males are performing.", "Nobody is jumping", "Conrad was being plotted against, to be hit on the head."]} +{"query": "A man giving a speech in a restaurant.", "pos": ["A person gives a speech."], "neg": ["The man sits at the table and eats food.", "This is definitely not an endorsement.", "They sold their home because they were retiring and not because of the loan.", "The seal of Missouri is perfect.", "Someone is raising their hand.", "An athlete is competing in the 1500 meter swimming competition.", "Two men watching a magic show."]} +{"query": "Indians having a gathering with coats and food and drinks.", "pos": ["A group of Indians are having a gathering with food and drinks"], "neg": ["A group of Indians are having a funeral", "It is only staged on Winter afternoons in Palma's large bullring.", "Right information can empower the legal service practices and the justice system. ", "Meanwhile, the mainland was empty of population.", "Two children is sleeping.", "a fisherman is trying to catch a monkey", "the people are in a train"]} +{"query": "A woman with violet hair rides her bicycle outside.", "pos": ["A woman is riding her bike."], "neg": ["A woman is jogging in the park.", "The street was lined with white-painted houses.", "A group watches a movie inside.", "man at picnics cut steak", "Several chefs are sitting down and talking about food.", "The Commission notes that no significant alternatives were considered.", "We ran out of firewood and had to use pine needles for the fire."]} +{"query": "A man pulls two women down a city street in a rickshaw.", "pos": ["A man is in a city."], "neg": ["A man is a pilot of an airplane.", "It is boring and mundane.", "The morning sunlight was shining brightly and it was warm. ", "Two people jumped off the dock.", "People watching a spaceship launch.", "Mother Teresa is an easy choice.", "It's worth being able to go at a pace you prefer."]} \ No newline at end of file From 80965ec26f128e7d0a6dd888b00e2a4f15cc3206 Mon Sep 17 00:00:00 2001 From: "Hee, Tyan Huey" Date: Thu, 4 Dec 2025 16:10:23 +0800 Subject: [PATCH 2/9] add more finetuning tests Signed-off-by: Hee, Tyan Huey --- .../005_test_funetuning_rerank.spec.ts | 4 ++ .../006_test_funetuning_embedding.spec.ts | 68 ++++++++++++++++++ .../007_test_funetuning_reasoning.spec.ts | 69 +++++++++++++++++++ tests/playwright/utils.ts | 2 +- tests/test-files/medical_o1_sft_50.json | 52 ++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts create mode 100644 tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts create mode 100644 tests/test-files/medical_o1_sft_50.json diff --git a/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts b/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts index 3e2694e..88c9f3c 100644 --- a/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts +++ b/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts @@ -61,4 +61,8 @@ test('005_test_funetuning_rerank', async ({ browser, baseURL }) => { await expect(page.getByRole('cell', { name: 'running' })).toHaveText('running'); await expect(page.locator('div').filter({ hasText: 'Fine-tuning JobsCreate New' }).nth(3)).toContainText('rerank'); await waitForStatusText(page, 'td.MuiTableCell-root div.MuiChip-root', 'succeeded', 20, 60000); + + await page.locator('button').nth(5).click(); + await page.getByRole('menuitem', { name: 'Delete Job' }).click(); + }); \ No newline at end of file diff --git a/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts b/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts new file mode 100644 index 0000000..c9b2a20 --- /dev/null +++ b/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { waitForStatusText } from '../utils'; +import path from 'path'; + +const trainDataset = path.resolve(__dirname, '../../test-files/toy_finetune_data.jsonl'); + +async function setupResponseListener(page, apiResponse) { + page.on('response', async (response) => { + if (response.url().includes('/v1/app-backend') && response.request().method() === 'POST') { + const contentType = response.headers()['content-type']; + if (contentType.includes('text/event-stream')) { + const responseBody = await response.text(); + // Parse SSE stream + const events = responseBody.split('\n\n'); + for (const event of events) { + const lines = event.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const cleanedData = line.slice(6, -1).trim(); // Remove 'data: ' prefix + apiResponse.value += cleanedData + " "; + } + } + } + } else { + console.error('Response is not SSE'); + } + } + }); +} + +test('006_test_funetuning_embedding', async ({ browser, baseURL }) => { + test.setTimeout(1200000); + let apiResponse = { value: '' }; + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + recordVideo: { + dir: './videos/', + size: { width: 1280, height: 720 } + } + }); + const page = await context.newPage(); + const IDC_URL = baseURL || "" + await page.goto(IDC_URL); + await page.getByLabel('Username or email').fill('test_automation@gmail.com'); + await page.getByLabel('Password', { exact: true }).click(); + await page.getByLabel('Password', { exact: true }).fill('test'); + await page.getByRole('button', { name: 'Sign In' }).click(); + await page.getByRole('button', { name: 'Fine-tuning' }).click(); + await page.getByRole('button', { name: 'Create New Job' }).click(); + await page.getByRole('combobox', { name: 'Base Model' }).click(); + await page.getByRole('option', { name: 'BAAI/bge-base-en-v1.5' }).click(); + await page.getByText('Instruction Tuning').click(); + await page.getByRole('option', { name: 'Embedding' }).click(); + let fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose File' }).click(); + let fileChooser = await fileChooserPromise; + await fileChooser.setFiles(trainDataset); + await page.waitForTimeout(5000); + await page.getByRole('button', { name: 'Create Job' }).click(); + await page.waitForTimeout(20000); + await expect(page.getByRole('cell', { name: 'running' })).toHaveText('running'); + await expect(page.locator('div').filter({ hasText: 'Fine-tuning JobsCreate New' }).nth(3)).toContainText('embedding'); + await waitForStatusText(page, 'td.MuiTableCell-root div.MuiChip-root', 'succeeded', 20, 60000); + + await page.locator('button').nth(5).click(); + await page.getByRole('menuitem', { name: 'Delete Job' }).click(); + +}); \ No newline at end of file diff --git a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts b/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts new file mode 100644 index 0000000..5ff7d2b --- /dev/null +++ b/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; +import { waitForStatusText } from '../utils'; +import path from 'path'; + +const trainDataset = path.resolve(__dirname, '../../test-files/medical_o1_sft_50.json'); + +async function setupResponseListener(page, apiResponse) { + page.on('response', async (response) => { + if (response.url().includes('/v1/app-backend') && response.request().method() === 'POST') { + const contentType = response.headers()['content-type']; + if (contentType.includes('text/event-stream')) { + const responseBody = await response.text(); + // Parse SSE stream + const events = responseBody.split('\n\n'); + for (const event of events) { + const lines = event.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const cleanedData = line.slice(6, -1).trim(); // Remove 'data: ' prefix + apiResponse.value += cleanedData + " "; + } + } + } + } else { + console.error('Response is not SSE'); + } + } + }); +} + +test('007_test_funetuning_reasoning', async ({ browser, baseURL }) => { + test.setTimeout(1200000); + let apiResponse = { value: '' }; + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + recordVideo: { + dir: './videos/', + size: { width: 1280, height: 720 } + } + }); + const page = await context.newPage(); + const IDC_URL = baseURL || "" + await page.goto(IDC_URL); + await page.getByLabel('Username or email').fill('test_automation@gmail.com'); + await page.getByLabel('Password', { exact: true }).click(); + await page.getByLabel('Password', { exact: true }).fill('test'); + await page.getByRole('button', { name: 'Sign In' }).click(); + await page.getByRole('button', { name: 'Fine-tuning' }).click(); + await page.getByRole('button', { name: 'Create New Job' }).click(); + await page.getByRole('combobox', { name: 'Base Model' }).click(); + await page.getByRole('option', { name: 'Qwen/Qwen2.5-7B' }).click(); + await page.getByText('Instruction Tuning').click(); + await page.getByRole('option', { name: 'Reasoning' }).click(); + let fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose File' }).click(); + let fileChooser = await fileChooserPromise; + await fileChooser.setFiles(trainDataset); + await page.waitForTimeout(5000); + await page.getByRole('button', { name: 'Create Job' }).click(); + await page.waitForTimeout(20000); + await expect(page.getByRole('cell', { name: 'running' })).toHaveText('running'); + await expect(page.locator('div').filter({ hasText: 'Fine-tuning JobsCreate New' }).nth(3)).toContainText('reasoning'); + await waitForStatusText(page, 'MuiChip-label MuiChip-labelSmall css-cxrmjv', 'succeeded', 20, 60000); + + await page.locator('button').nth(5).click(); + await page.getByRole('menuitem', { name: 'Delete Job' }).click(); + + +}); \ No newline at end of file diff --git a/tests/playwright/utils.ts b/tests/playwright/utils.ts index 1a299d2..92bd6ce 100644 --- a/tests/playwright/utils.ts +++ b/tests/playwright/utils.ts @@ -4,7 +4,7 @@ export async function waitForStatusText(page: any, selector: string, statusText: for (let i = 0; i < maxAttempts; i++) { try { const text = await page.locator(selector).first().innerText(); - if (text === 'Error') { + if (text === 'Error' ||text === 'failed') { throw new Error(`Encountered unwanted status text "Error" in element "${selector}"`); } await expect(page.locator(selector).first()).toHaveText(statusText, { timeout: intervalTimeout }); diff --git a/tests/test-files/medical_o1_sft_50.json b/tests/test-files/medical_o1_sft_50.json new file mode 100644 index 0000000..3afe666 --- /dev/null +++ b/tests/test-files/medical_o1_sft_50.json @@ -0,0 +1,52 @@ +[ + { + "Question": "Given the symptoms of sudden weakness in the left arm and leg, recent long-distance travel, and the presence of swollen and tender right lower leg, what specific cardiac abnormality is most likely to be found upon further evaluation that could explain these findings?", + "Complex_CoT": "Okay, let's see what's going on here. We've got sudden weakness in the person's left arm and leg - and that screams something neuro-related, maybe a stroke?\n\nBut wait, there's more. The right lower leg is swollen and tender, which is like waving a big flag for deep vein thrombosis, especially after a long flight or sitting around a lot.\n\nSo, now I'm thinking, how could a clot in the leg end up causing issues like weakness or stroke symptoms?\n\nOh, right! There's this thing called a paradoxical embolism. It can happen if there's some kind of short circuit in the heart - like a hole that shouldn't be there.\n\nLet's put this together: if a blood clot from the leg somehow travels to the left side of the heart, it could shoot off to the brain and cause that sudden weakness by blocking blood flow there.\n\nHmm, but how would the clot get from the right side of the heart to the left without going through the lungs and getting filtered out?\n\nHere's where our cardiac anomaly comes in: a patent foramen ovale or PFO. That's like a sneaky little shortcut in the heart between the right and left atria.\n\nAnd it's actually pretty common, found in about a quarter of adults, which definitely makes it the top suspect here.\n\nSo with all these pieces - long travel, leg clot, sudden weakness - a PFO fits the bill perfectly, letting a clot cross over and cause all this.\n\nEverything fits together pretty neatly, so I'd bet PFO is the heart issue waiting to be discovered. Yeah, that really clicks into place!", + "Response": "The specific cardiac abnormality most likely to be found in this scenario is a patent foramen ovale (PFO). This condition could allow a blood clot from the venous system, such as one from a deep vein thrombosis in the leg, to bypass the lungs and pass directly into the arterial circulation. This can occur when the clot moves from the right atrium to the left atrium through the PFO. Once in the arterial system, the clot can travel to the brain, potentially causing an embolic stroke, which would explain the sudden weakness in the left arm and leg. The connection between the recent travel, which increases the risk of deep vein thrombosis, and the neurological symptoms suggests the presence of a PFO facilitating a paradoxical embolism." + }, + { + "Question": "A 33-year-old woman is brought to the emergency department 15 minutes after being stabbed in the chest with a screwdriver. Given her vital signs of pulse 110/min, respirations 22/min, and blood pressure 90/65 mm Hg, along with the presence of a 5-cm deep stab wound at the upper border of the 8th rib in the left midaxillary line, which anatomical structure in her chest is most likely to be injured?", + "Complex_CoT": "Okay, let's figure out what's going on here. A woman comes in with a stab wound from a screwdriver. It's in her chest, upper border of the 8th rib, left side, kind of around the midaxillary line. First thought, that's pretty close to where the lung sits, right?\n\nLet's talk about location first. This spot is along the left side of her body. Above the 8th rib, like that, is where a lot of important stuff lives, like the bottom part of the left lung, possibly the diaphragm too, especially considering how deep the screwdriver went.\n\nThe wound is 5 cm deep. That sounds pretty deep. I mean, it could definitely reach down to the lung tissue or maybe the diaphragm. Given that it's midaxillary, we're in the territory where the lower lobe of the left lung hangs out. It's also possible there's some intersection with where the diaphragm begins, but the lung feels more probable somehow.\n\nNow, her vitals are concerning: elevated heart rate and low blood pressure. This is serious. My gut says that this kind of vital sign picture could mean something like pneumothorax or maybe hemothorax. Both can happen if the lung gets punctured, and they can make the blood pressure drop and the heart rate skyrocket since she's obviously distressed.\n\nSo, putting it all together, the most obvious culprit is the lower lobe of the left lung. The wound's depth and her condition point that way. And, yeah, this adds up with pneumothorax or maybe blood collecting in the chest—the kind of stuff that can really mess with breathing and circulation.\n\nAlright, with this in mind, it sure seems like the lung is the most likely thing that got hurt here. Makes sense given the situation, where the wound is, considering her symptoms, and the whole setup.", + "Response": "In this scenario, the most likely anatomical structure to be injured is the lower lobe of the left lung. The location of the stab wound—at the upper border of the 8th rib in the left midaxillary line—indicates proximity to the lower lobe of the lung. The depth of the wound (5 cm) suggests it is sufficient to reach lung tissue. Her vital signs of elevated heart rate and low blood pressure could signal complications like a pneumothorax or hemothorax, common consequences of lung trauma that would result from a penetrating injury in this area. Given these considerations, the lower lobe of the left lung is the most probable structure injured." + }, + { + "Question": "A 61-year-old woman with a long history of involuntary urine loss during activities like coughing or sneezing but no leakage at night undergoes a gynecological exam and Q-tip test. Based on these findings, what would cystometry most likely reveal about her residual volume and detrusor contractions?", + "Complex_CoT": "Okay, let's think about this step by step. There's a 61-year-old woman here who's been dealing with involuntary urine leakages whenever she's doing something that ups her abdominal pressure like coughing or sneezing. This sounds a lot like stress urinary incontinence to me. Now, it's interesting that she doesn't have any issues at night; she isn't experiencing leakage while sleeping. This likely means her bladder's ability to hold urine is fine when she isn't under physical stress. Hmm, that's a clue that we're dealing with something related to pressure rather than a bladder muscle problem. \n\nThe fact that she underwent a Q-tip test is intriguing too. This test is usually done to assess urethral mobility. In stress incontinence, a Q-tip might move significantly, showing urethral hypermobility. This kind of movement often means there's a weakness in the support structures that should help keep the urethra closed during increases in abdominal pressure. So, that's aligning well with stress incontinence.\n\nNow, let's think about what would happen during cystometry. Since stress incontinence isn't usually about sudden bladder contractions, I wouldn't expect to see involuntary detrusor contractions during this test. Her bladder isn't spasming or anything; it's more about the support structure failing under stress. Plus, she likely empties her bladder completely because stress incontinence doesn't typically involve incomplete emptying. So, her residual volume should be pretty normal. \n\nAll in all, it seems like if they do a cystometry on her, it will likely show a normal residual volume and no involuntary contractions. Yup, I think that makes sense given her symptoms and the typical presentations of stress urinary incontinence.", + "Response": "Cystometry in this case of stress urinary incontinence would most likely reveal a normal post-void residual volume, as stress incontinence typically does not involve issues with bladder emptying. Additionally, since stress urinary incontinence is primarily related to physical exertion and not an overactive bladder, you would not expect to see any involuntary detrusor contractions during the test." + }, + { + "Question": "A 45-year-old man with a history of alcohol use, who has been abstinent for the past 10 years, presents with sudden onset dysarthria, shuffling gait, and intention tremors. Given this clinical presentation and history, what is the most likely diagnosis?", + "Complex_CoT": "Alright, let’s break this down. We have a 45-year-old man here, who suddenly starts showing some pretty specific symptoms: dysarthria, shuffling gait, and those intention tremors. This suggests something's going wrong with motor control, probably involving the cerebellum or its connections.\n\nNow, what's intriguing is that he's had a history of alcohol use, but he's been off it for the past 10 years. Alcohol can do a number on the cerebellum, leading to degeneration, and apparently, the effects can hang around or even appear long after one stops drinking.\n\nAt first glance, these symptoms look like they could be some kind of chronic degeneration, maybe something like alcoholic cerebellar degeneration, but hold on. This looks different. The symptoms just came on all of a sudden. Chronic degenerations typically have a more gradual onset.\n\nOkay, let’s reconsider this sudden nature. It’s making me think of something more acute, more rapid onset. Hmm, if we dig back into his history of drinking, there might have been some damage done, leading to nutritional deficiencies, like a lack of thiamine.\n\nWernicke’s encephalopathy is a classic possibility here. That condition pops up due to thiamine deficiency, often after a history of alcohol use. It’s known for causing ataxia, confusion, and eye movement issues. However, he’s not showing the full triad of symptoms; there's no mention of confusion or eye problems, so maybe it doesn’t fit perfectly.\n\nOh, wait a second, maybe we're missing something simpler. Given the acute nature of the symptoms, maybe this is more in line with something like a stroke. Sudden onset can definitely suggest a vascular event. With his alcohol history, he’s at risk for things like high blood pressure, which can increase stroke risk.\n\nIn particular, lacunar strokes can mess with motor coordination, speech, the works. These are small, but significant enough to cause these exact symptoms: dysarthria, tremors, and a shuffling gait.\n\nBut hang on, what if there’s another angle we’re missing? Chronic alcohol effects might still play a role here, just in a different way. There’s a condition called acquired hepatocerebral degeneration. This can happen in people with a history of liver disease due to alcohol, even years after they’ve quit. It can result in symptoms like these because of how substances get deposited in the brain.\n\nLinking this back to our patient, his history with alcohol could’ve led to some liver issues, and now, here we are with these symptoms showing up suddenly. Makes sense, right? So, considering everything, acquired hepatocerebral degeneration seems to fit quite nicely. That’s probably our most fitting diagnosis for this situation.", + "Response": "Considering the clinical presentation of sudden onset dysarthria, shuffling gait, and intention tremors in a 45-year-old man with a history of alcohol use who has been abstinent for the past 10 years, the most likely diagnosis is acquired hepatocerebral degeneration.\n\nThis condition is associated with chronic liver disease, which can often be a consequence of long-term alcohol use. Despite the patient's abstinence from alcohol for a decade, previous alcohol use may have led to underlying liver dysfunction. This dysfunction, even if subclinical, can cause encephalopathy due to the accumulation of neurotoxic substances that affect the brain. The sudden onset of these neurological symptoms aligns with how acquired hepatocerebral degeneration can manifest, making it a probable diagnosis in this scenario." + }, + { + "Question": "A 45-year-old man presents with symptoms including a wide-based gait, a blank facial expression, hallucinations, memory issues, a resting tremor that resolves with movement, and bradykinesia. Based on these clinical findings, what is most likely to be observed in the histological specimen of his brain?", + "Complex_CoT": "Okay, so here's a 45-year-old guy who's experiencing some pretty concerning symptoms. Let's break this down. He's got a wide-based gait. Right off the bat, that makes me think about balance and coordination issues; something affecting his motor control could be at play here.\n\nThen there's the blank facial expression. That's often referred to as facial masking, commonly seen in Parkinson's disease due to a lack of facial muscle movement.\n\nHallucinations and memory issues, though, those are interesting. Hallucinations make me think about some sort of cognitive or psychiatric involvement. Memory issues definitely point towards a possibility of cognitive decline.\n\nNow, a resting tremor that stops with movement is a classic sign of Parkinson's. It's one of those distinguishing features. Paired with bradykinesia, which means slowness of movement, these two symptoms are screaming Parkinson’s disease to me.\n\nWide-based gait might initially make one consider cerebellar issues, but in the context of Parkinsonism, it can also be due to balance problems stemming from basal ganglia dysfunction or even Parkinson's-related gait disturbances.\n\nWhen I think about all these symptoms coming together, it certainly makes Parkinson's disease a strong contender, especially considering his age and the symptom profile.\n\nBut here's the thing, I can't ignore the cognitive and psychiatric symptoms. These could suggest something like Lewy body dementia, which is related but incorporates more of the psychiatric features alongside Parkinsonian symptoms.\n\nIn terms of what's going on at the cellular level, Parkinson's disease and dementia with Lewy bodies share a common pathological hallmark: the presence of Lewy bodies. These are clumps of proteins, mainly alpha-synuclein, that accumulate in the brain cells and can cause both movement and cognitive problems.\n\nSo, taking all of this into account, when they look at the brain tissue under a microscope, I would expect them to find Lewy bodies. Those would explain the whole picture—movement issues, cognitive decline, and hallucinations. It ties everything together nicely.", + "Response": "Based on the clinical findings presented—wide-based gait, blank facial expression, hallucinations, memory issues, resting tremor that resolves with movement, and bradykinesia—it is likely that the 45-year-old man is experiencing a condition related to Parkinsonism, possibly Parkinson's disease or dementia with Lewy bodies. Both of these conditions are associated with the presence of Lewy bodies in the brain. Lewy bodies are abnormal aggregates of protein, primarily alpha-synuclein, which can cause both the motor and cognitive symptoms observed in this patient. Therefore, in the histological specimen of his brain, you would most likely observe the presence of Lewy bodies." + }, + { + "Question": "A patient with psoriasis was treated with systemic steroids, and upon discontinuation of the treatment, developed generalized pustules all over the body. What is the most likely cause of this condition?", + "Complex_CoT": "I'm thinking about this patient with psoriasis who's been on systemic steroids. Okay, so psoriasis is this chronic skin condition that can be pretty stubborn and flares up now and then. They stopped their steroid treatment, and then suddenly there are these generalized pustules appearing all over the place. What's going on here? \n\nAlright, pustules are like little bumps that are filled with pus—they can be a sign of different skin issues, but in someone with psoriasis, a major change like stopping treatment can really mess with things. Steroids are powerful at tamping down inflammation and managing the immune system. When you take someone off steroids, particularly suddenly, the body can react in unexpected ways. It's like the immune system gets a bit confused and overreacts—a rebound effect. \n\nOh, right, I remember reading that systemic steroids can sometimes mask the severity of psoriasis, keeping it under control. But pulling those steroids too fast can allow the psoriasis to come back stronger, even shifting into another form. This sounds familiar—there's a type called pustular psoriasis. If I recall, that's when the psoriasis turns into this more extreme version with pustules. \n\nSo connecting the dots: it's likely that stopping the steroids led to a rebound and we might be seeing a case of generalized pustular psoriasis. That makes sense given the scenario—I've seen this mentioned before where physicians need to be cautious about how they taper steroids off in psoriasis patients to prevent exactly this kind of escalation. It fits pretty well with what I'd expect based on what I know about steroid withdrawal and psoriasis flare-ups.", + "Response": "The development of generalized pustules in a patient with psoriasis following the discontinuation of systemic steroid treatment is most likely due to a condition called generalized pustular psoriasis (GPP). This condition can occur as a rebound effect when systemic steroids are suddenly stopped or rapidly tapered down. In patients with psoriasis, steroids can suppress the immune system and mitigate symptoms. When these drugs are withdrawn too quickly, the immune system can overreact, leading to more severe symptoms, including the transformation of psoriasis into a pustular form. Careful management and slow tapering of steroids are critical in psoriasis patients to prevent such exacerbations." + }, + { + "Question": "What is the most likely diagnosis for a 2-year-old 70 kg child who presents with limitation of abduction and internal rotation, tenderness in Scarpa's triangle, and abduction of the limb upon flexing the hip?", + "Complex_CoT": "Okay, so we're dealing with a 2-year-old child here. But wait, this kid is listed as 70 kg – that's a lot for a toddler! It might be a typo, or maybe there's something else going on. Anyway, let's focus on the symptoms.\n\nThis child can't abduct the hip or internally rotate it very well. That generally screams hip issues. We're looking at something more than just temporary discomfort, I think.\n\nThere's tenderness in the Scarpa’s triangle. That points us right to the hip, to something maybe structural or possibly some inflammation around the joint. So, the focus needs to stay around the hip region.\n\nHere's something interesting: when you flex the hip, the limb abducts. That feels like a tell-tale sign. Normally, you'd associate this with instability or maybe a slipped joint or something affecting the way the joint moves.\n\nTransient synovitis is a common condition in young kids that affects the hip – it makes sense initially since it causes hip pain and a limp. But, hmm, that doesn’t line up perfectly with the limb movement upon flexion.\n\nWe must consider hip dysplasia or even something affecting joint congruence, but keeping age in mind, these shouldn't be the first thoughts.\n\nNow, considering this obvious weight issue combined with hip symptoms: has to lead us to think about conditions tied to growth or structural issues.\n\nOn the other hand, slipped capital femoral epiphysis (SCFE) is more common in older kids, true, but it's also linked with heavier weight, and that annoying abduction on flexion does fit.\n\nLet’s face it, the child’s weight and the weird abduction movement on flexion make SCFE pretty suspicious, even if the kid is very young for it.\n\nLogically, considering all these factors and given the hip symptoms and the weight, the signs point more convincingly towards SCFE. And yes, this seems like the most plausible conclusion given the current scenario. Even if it's a stretch for their age.", + "Response": "Based on the described symptoms and the unusual weight for a 2-year-old child, the most likely diagnosis is Slipped Capital Femoral Epiphysis (SCFE). Even though SCFE typically occurs in older children, mainly adolescents, the combination of excessive weight, limited hip abduction and internal rotation, tenderness in the hip area, and the characteristic limb movement (abduction upon hip flexion) strongly points towards SCFE as the most plausible diagnosis in this scenario." + }, + { + "Question": "Scientists are developing a new non-steroidal anti-inflammatory drug for osteoarthritis, aiming for higher potency but the same efficacy as ibuprofen to reduce gastrointestinal side effects. If ibuprofen is represented by curve C in the figure and the desired therapeutic effect is marked by the dashed line Y, what curve would represent the new drug that fulfills these criteria?", + "Complex_CoT": "Okay, so I need to figure out the characteristics of this new drug compared to ibuprofen. Let's see, we're looking at two main things here: potency and efficacy. If I remember correctly, potency is about how much of a drug we need to get a particular effect. So if a drug is more potent, we need less of it to get the job done. Efficacy, on the other hand, is about how good the drug is at its best; it's the maximum effect it can achieve.\n\nNow, for this new drug, they want it to be more potent than ibuprofen. That means it should work at a lower dose. But they still want it to be just as effective in terms of the maximum impact – so, same efficacy.\n\nThinking about how this would look on a graph, if I have an efficacy curve for ibuprofen already, any drug that's more potent will have its curve shift to the left. Why? Oh right, because it achieves the effect at a lower dose. But the peak of the curve, or the height, would stay the same because our maximum effect, or efficacy, isn't changing.\n\nAlright, looking for which curve could represent the new drug requires me to find one that hits the desired effect line – they mentioned a dashed line Y – at a lower concentration than ibuprofen, but still reaches the same top level or height.\n\nHmm, with this in mind, I need to verify with the graph. Curve C is for ibuprofen, so I need to spot another curve that rises quicker and hits line Y before ibuprofen does. But crucially, it has to level out at the same height, or else it won't meet the efficacy requirement.\n\nOh, I see. The new drug's curve needs to not just start lower at achieving effects but also maintain its power up to the height of line Y. This means while the curve is left compared to Curve C, it should still touch the same maximum point.\n\nThat's clear in my head now. A drug curve to the left of C that reaches the same ultimate height signifies greater potency without sacrificing efficacy. Perfect, this should match the target for the new drug!", + "Response": "To determine which curve would represent the new drug meeting the criteria of higher potency but the same efficacy as ibuprofen (represented by curve C), we need to find a curve that achieves the desired therapeutic effect (marked by the dashed line Y) at a lower concentration than curve C. Additionally, this curve should plateau at the same maximum effect level as curve C to indicate identical efficacy.\n\nThis means we are looking for a curve that is shifted to the left of curve C, indicating the drug requires a lower dose to achieve the therapeutic effect, without surpassing the height or plateau level of curve C, ensuring the same maximum efficacy.\n\nIf you have a set of labeled curves to choose from, the ideal curve for the new drug would be the one that intersects the dashed line Y before curve C does, but at the same height. Please identify such a curve based on your graph's available options." + }, + { + "Question": "A 15-year-old boy presents with decreased facial hair, bilateral breast enlargement, long extremities, scant pubic hair, small testes, and normal-sized penis. He is at the 97th percentile for height and the 50th percentile for weight. What karyotype is most likely to be found in further evaluation?", + "Complex_CoT": "Alright, we have a 15-year-old boy with some intriguing symptoms. He’s got decreased facial and pubic hair and that’s worth noting right away because these are signs that sexual development might be off.\n\nThen there’s the breast enlargement—gynecomastia—and, interestingly, his limbs are unusually long. It sounds like his body proportions fall into what’s sometimes called eunuchoid proportions. That’s quite a specific term, but it basically means he’s got long arms and legs relative to his torso.\n\nAnother telltale sign is his small testes, but he has a normal-sized penis. This combination usually points towards an imbalance in sex hormones, often linked with hypogonadism.\n\nNow, when we consider his height, he’s at the 97th percentile. This is quite tall, especially when compared to his average weight at the 50th percentile. Hmm, this hint about height immediately brings Klinefelter syndrome to mind because guys with this condition are often taller due to that extra X chromosome.\n\nEverything seems to fit together like puzzle pieces: gynecomastia, hypogonadism, long limbs, and his tall stature. These are classic symptoms of Klinefelter.\n\nSo, what’s going on at the chromosomal level? If he has Klinefelter syndrome, it means his karyotype would typically be 47,XXY. That extra X chromosome explains a lot about these symptoms.\n\nGiven all of this, I’m confident in saying that 47,XXY is the most likely karyotype we’d see in this kid. Yep, everything aligns well with Klinefelter syndrome. That makes perfect sense!", + "Response": "Given the symptoms described, the most likely karyotype for this 15-year-old boy is 47,XXY, which is characteristic of Klinefelter syndrome. The combination of decreased facial and pubic hair, gynecomastia, small testes, long extremities, and tall stature aligns with this chromosomal pattern. Klinefelter syndrome is caused by the presence of an extra X chromosome, leading to the 47,XXY karyotype." + }, + { + "Question": "In a case where a child with pneumonia is diagnosed, and their chest X-ray indicates a pattern consistent with a common bacterial infection, what is the most likely causative organism based on typical presentations?", + "Complex_CoT": "Okay, so we have a child diagnosed with pneumonia, and their chest X-ray shows a pattern. Let's see... Pneumonia in children, hmm... the most common bacterial culprit is usually Streptococcus pneumoniae, right? I've read it's quite a leading cause in these cases. Now, about the chest X-ray... what did it show again? Ah, lobar consolidation. That's a classic sign for bacterial infections, especially when we're talking about Streptococcus pneumoniae. It tends to show these sorts of patterns. Children with bacterial pneumonia often exhibit symptoms like a high fever, productive cough, and maybe even trouble breathing. These seem to match up with what we have on the X-ray: lobar consolidation. Yep, this lines up well. So it looks like Streptococcus pneumoniae is probably the organism causing this.\n\nBut, wait a sec, let's just verify and consider other possibilities too. Other bacteria can cause pneumonia in kids, like Haemophilus influenzae or maybe Mycoplasma pneumoniae, among others. However, Mycoplasma pneumoniae usually shows a different kind of X-ray pattern — more diffuse, more interstitial changes — and I doubt it's the same situation here. What about Haemophilus influenzae? Well, it could show similar X-ray findings, but it's not as prevalent as Streptococcus pneumoniae.\n\nFor shallow confirmation... The symptoms we're seeing, the lobar consolidation—all this strongly aligns with Streptococcus pneumoniae. It's a common cause after all. I should also consider Staphylococcus aureus, especially if there are severe symptoms or unusual X-ray features like round pneumonia—yes, that's something Staph might do. But if we are just sticking with the typical presentations and commonality in children, Streptococcus pneumoniae really seems to fit the bill.\n\nLet's not forget, Staphylococcus aureus can sometimes show distinctive round or sometimes patchy infiltrates on X-rays, and can lead to more severe symptoms, which might make you think twice. However, unless there's specific clinical context—like recent skin infections—it might not be the first go-to diagnosis for a straightforward case like this.\n\nSo, all things considered, if we're talking about likelihood and fitting the most common presentation seen on a chest X-ray in a child with pneumonia, I would feel confident saying it's Streptococcus pneumoniae. Still, keep an eye on any variations in the X-ray or clinical presentation that might point toward Staph or something else.", + "Response": "In a case where a child's pneumonia diagnosis is supported by a chest X-ray showing a pattern of lobar consolidation, the most likely causative organism is Streptococcus pneumoniae. This bacterium is the leading cause of bacterial pneumonia in children and typically presents with the kind of X-ray patterns described. Other bacteria can also cause pneumonia, but based on commonality and typical presentation, Streptococcus pneumoniae is the most probable culprit." + } +] \ No newline at end of file From 0db5ed2ebc08c10d3f2998a504ed856525e2002e Mon Sep 17 00:00:00 2001 From: wwanarif Date: Sun, 7 Dec 2025 20:18:49 +0800 Subject: [PATCH 3/9] fix finetuning issues + add error UI for import sample workflow fails Signed-off-by: wwanarif --- studio-frontend/packages/server/package.json | 6 +- .../server/src/services/finetuning/index.ts | 433 +++++++++++++----- .../views/finetuning/FinetuningJobsTable.jsx | 51 ++- .../packages/ui/src/views/opeaflows/index.jsx | 27 +- .../005_test_funetuning_rerank.spec.ts | 2 +- .../006_test_funetuning_embedding.spec.ts | 2 +- .../007_test_funetuning_reasoning.spec.ts | 3 +- 7 files changed, 374 insertions(+), 150 deletions(-) diff --git a/studio-frontend/packages/server/package.json b/studio-frontend/packages/server/package.json index 7ef55d6..fef719b 100644 --- a/studio-frontend/packages/server/package.json +++ b/studio-frontend/packages/server/package.json @@ -87,7 +87,8 @@ "typeorm": "^0.3.6", "uuid": "^9.0.1", "winston": "^3.9.0", - "https-proxy-agent": "^7.0.4" + "https-proxy-agent": "^7.0.4", + "archiver": "^6.0.1" }, "devDependencies": { "@types/content-disposition": "0.5.8", @@ -105,6 +106,7 @@ "start-server-and-test": "^2.0.3", "ts-node": "^10.7.0", "tsc-watch": "^6.0.4", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "@types/archiver": "^6.0.2" } } diff --git a/studio-frontend/packages/server/src/services/finetuning/index.ts b/studio-frontend/packages/server/src/services/finetuning/index.ts index 0fed1c8..f52ea56 100644 --- a/studio-frontend/packages/server/src/services/finetuning/index.ts +++ b/studio-frontend/packages/server/src/services/finetuning/index.ts @@ -6,6 +6,7 @@ import * as path from 'path' import { exec } from 'child_process' import { promisify } from 'util' import { StatusCodes } from 'http-status-codes' +import archiver from 'archiver' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' @@ -77,24 +78,42 @@ const ensureFineTuningOutputZip = async (outputDir: string, jobId: string): Prom } } - // Create zip file using tar (more efficient than node zip libraries) + // Create zip file using archiver (standard ZIP format compatible with Windows) // eslint-disable-next-line no-console console.debug(`finetuningService.ensureFineTuningOutputZip - starting to zip output for job ${jobId}`) try { - const parentDir = path.dirname(outputDir) - const dirName = path.basename(outputDir) - const cmd = `cd "${parentDir}" && tar -czf "${path.basename(zipFilePath)}" "${dirName}"` - await execAsync(cmd, { - maxBuffer: 1024 * 1024 * 100, // 100MB buffer for large outputs - timeout: 600000 // 10 minute timeout + return await new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipFilePath) + const archive = archiver('zip', { + zlib: { level: 6 } // compression level + }) + + output.on('close', () => { + // eslint-disable-next-line no-console + console.debug(`finetuningService.ensureFineTuningOutputZip - zip created successfully for job ${jobId}: ${zipFilePath} (${archive.pointer()} bytes)`) + resolve(zipFilePath) + }) + + output.on('error', (err: any) => { + // eslint-disable-next-line no-console + console.error(`finetuningService.ensureFineTuningOutputZip - write stream error: ${err?.message || err}`) + reject(err) + }) + + archive.on('error', (err: any) => { + // eslint-disable-next-line no-console + console.error(`finetuningService.ensureFineTuningOutputZip - archiver error: ${err?.message || err}`) + reject(err) + }) + + archive.pipe(output) + // Add the entire directory to the archive with basename as root + archive.directory(outputDir, path.basename(outputDir)) + archive.finalize() }) - - // eslint-disable-next-line no-console - console.debug(`finetuningService.ensureFineTuningOutputZip - zip created successfully for job ${jobId}: ${zipFilePath}`) - return zipFilePath } catch (execErr: any) { // eslint-disable-next-line no-console - console.error(`finetuningService.ensureFineTuningOutputZip - tar failed for job ${jobId}: ${execErr?.message || execErr}`) + console.error(`finetuningService.ensureFineTuningOutputZip - archiver failed for job ${jobId}: ${execErr?.message || execErr}`) return null } } catch (error: any) { @@ -248,136 +267,298 @@ const updateJobInDb = async (jobId: string, updates: Partial) => { } } -/** - * Create a fine-tuning job - */ -const createFineTuningJob = async (jobConfig: { - training_file: string - model: string - General?: { - task?: string - lora_config?: any + +// Utility: convert "true"/"false" (string) to boolean; leave non-boolean inputs as-is. +const coerceBooleanString = (v: any): any => { + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + if (s === 'true') return true; + if (s === 'false') return false; + } + return v; +}; + +// Utility: ensure padding is one of allowed values or a boolean. +// If pad_to_max is true, force "max_length". +const sanitizePadding = (dataset: any) => { + if (!dataset) return; + + // Coerce common string booleans first + if (dataset.hasOwnProperty('padding')) { + const val = dataset.padding; + const coerced = coerceBooleanString(val); + + // Allowed enum values + const allowedEnums = ['longest', 'max_length', 'do_not_pad'] as const; + + if (typeof coerced === 'boolean') { + dataset.padding = coerced; // transformers accepts boolean true/false + } else if (typeof coerced === 'string') { + const s = coerced.trim().toLowerCase(); + if (allowedEnums.includes(s as any)) { + dataset.padding = s; // valid enum string + } else if (s === 'true') { + // Defensive: sometimes people pass "true" explicitly + dataset.padding = true; + } else if (s === 'false') { + dataset.padding = false; + } else { + // Fallback: pick a safe default + dataset.padding = 'max_length'; + } + } else { + // Fallback if something weird comes in + dataset.padding = 'max_length'; } - Dataset?: { - max_length?: number - query_max_len?: number - passage_max_len?: number - padding?: string + } + + // If pad_to_max is present as string, coerce to boolean. + if (dataset.hasOwnProperty('pad_to_max')) { + dataset.pad_to_max = coerceBooleanString(dataset.pad_to_max); + } + + // If pad_to_max is true, padding MUST be "max_length" for consistency + if (dataset.pad_to_max === true) { + dataset.padding = 'max_length'; + // Also ensure max_length is set when needed + if (!dataset.max_length && (dataset.query_max_len || dataset.passage_max_len)) { + // If per-type max lengths exist, we can keep them; otherwise default global max_length. + dataset.max_length = Math.max( + Number(dataset.query_max_len || 0), + Number(dataset.passage_max_len || 0), + 512 + ); } - Training?: { - epochs?: number - batch_size?: number - gradient_accumulation_steps?: number + } +}; + +// Utility: coerce other Dataset booleans and align preprocessor with task. +const sanitizeDataset = (payload: any) => { + const ds = payload?.Dataset; + if (!ds) return; + + // Coerce known booleans that might arrive as strings + if (ds.hasOwnProperty('truncation')) ds.truncation = coerceBooleanString(ds.truncation); + if (ds.hasOwnProperty('mask_input')) ds.mask_input = coerceBooleanString(ds.mask_input); + if (ds.hasOwnProperty('mask_response')) ds.mask_response = coerceBooleanString(ds.mask_response); + + sanitizePadding(ds); + + // Align preprocessor with task if embedding + const task = payload?.General?.task || payload?.task; + if (task === 'embedding') { + if (ds.data_preprocess_type === 'neural_chat') { + ds.data_preprocess_type = 'embedding'; } -}) => { - try { - // Work with the jobConfig as-provided by the UI. - const forwardedJobConfig = { ...jobConfig } + // Masking typically not used for embeddings + if (typeof ds.mask_input !== 'undefined') ds.mask_input = false; + if (typeof ds.mask_response !== 'undefined') ds.mask_response = false; + } + + // Optional: ensure padding_side/truncation_side sanity (keep defaults if not set) + if (!ds.padding_side) ds.padding_side = 'right'; + if (!ds.truncation_side) ds.truncation_side = 'right'; +}; + +// Utility: avoid DDP for single-worker CPU runs (if these fields are present) +const sanitizeTraining = (payload: any) => { + const tr = payload?.Training; + if (!tr) return; + // If accelerate_mode is DDP but workers <= 1, remove accelerate_mode + // so the backend can choose a sensible default. + const workers = Number(tr.num_training_workers ?? 1); + const device = (tr.device ?? '').toString().toLowerCase(); + if (tr.accelerate_mode === 'DDP' && workers <= 1) { + try { + delete tr.accelerate_mode + } catch (e) { + // Fallback: set to undefined to avoid sending an invalid literal + // when the payload is serialized. + // eslint-disable-next-line no-param-reassign + tr.accelerate_mode = undefined as any + } +} - // (Removed verbose initial jobConfig logging to reduce noise) - const sanitizedPayload = JSON.parse(JSON.stringify(forwardedJobConfig)) + // Optional: if device unspecified but accelerate_mode DDP, leave as-is; trainer may decide backend. +}; - // Remove empty nested objects that may confuse the server - if (sanitizedPayload.General && Object.keys(sanitizedPayload.General).length === 0) { - delete sanitizedPayload.General - } - if (sanitizedPayload.Dataset && Object.keys(sanitizedPayload.Dataset).length === 0) { - delete sanitizedPayload.Dataset - } - if (sanitizedPayload.Training && Object.keys(sanitizedPayload.Training).length === 0) { - delete sanitizedPayload.Training - } - - if (sanitizedPayload.training_file && typeof sanitizedPayload.training_file === 'string') { - const originalFilename = sanitizedPayload.training_file - - // Try to decode first in case it's URL-encoded - let lookupKey = originalFilename - try { - const decoded = decodeURIComponent(originalFilename) - lookupKey = decoded - } catch (e) { - // ignore decode errors - } - - // Check if we have a stored mapping from the upload - let stored = uploadedFileIdMap.get(lookupKey) - if (!stored && lookupKey !== originalFilename) { - // Also try the original (encoded) key - stored = uploadedFileIdMap.get(originalFilename) - } - - if (stored && stored.rawFilename) { - sanitizedPayload.training_file = stored.rawFilename - } - } +// Central sanitizer that mutates the payload +const sanitizeFineTuningPayload = (payload: any) => { + if (!payload || typeof payload !== 'object') return payload; - // Try a sequence of attempts to accommodate naming/encoding/id differences. - const attemptPost = async (payload: any, label = 'attempt') => { - try { - // eslint-disable-next-line no-console - console.debug(`finetuningService.createFineTuningJob - ${label} payload:`, payload) - const resp = await axiosClient.post('/v1/fine_tuning/jobs', payload) - // eslint-disable-next-line no-console - console.debug(`finetuningService.createFineTuningJob - ${label} response:`, typeof resp?.data === 'string' ? resp.data : JSON.stringify(resp?.data)) - return resp - } catch (err: any) { - // Log detailed info for debugging - try { - // eslint-disable-next-line no-console - console.error(`finetuningService.createFineTuningJob - ${label} failed`, { - message: err?.message, - status: err?.response?.status, - responseData: typeof err?.response?.data === 'string' ? err.response.data : JSON.stringify(err?.response?.data), - payload - }) - } catch (logErr) { - // ignore logging errors - } - throw err + // Coerce top-level task if provided as string-like (no-op if already OK) + if (payload.General && typeof payload.General.task === 'string') { + payload.General.task = payload.General.task.trim(); + } + + // If lora_config is explicitly null/empty, remove it entirely so we don't + // send `lora_config: null` to the backend which may assert on it. + try { + if (payload.General && Object.prototype.hasOwnProperty.call(payload.General, 'lora_config')) { + const lc = payload.General.lora_config + const isEmptyObject = lc && typeof lc === 'object' && Object.keys(lc).length === 0 + if (lc === null || typeof lc === 'undefined' || isEmptyObject) { + delete payload.General.lora_config } } + } catch (e) { + // ignore + } - // Send the sanitized payload - const resp = await attemptPost(sanitizedPayload, 'final') - const respData = resp.data - // If the external service didn't echo back the task, preserve task from our sanitized payload + // Apply dataset and training sanitizers + sanitizeDataset(payload); + sanitizeTraining(payload); + + return payload; +}; + +const createFineTuningJob = async (jobConfig: { + training_file: string; + model: string; + General?: { + task?: string; + lora_config?: any; + }; + Dataset?: { + max_length?: number; + query_max_len?: number; + passage_max_len?: number; + padding?: string; + }; + Training?: { + epochs?: number; + batch_size?: number; + gradient_accumulation_steps?: number; + }; +}) => { + try { + // Work with the jobConfig as-provided by the UI. + const forwardedJobConfig = { ...jobConfig }; + + // (Removed verbose initial jobConfig logging to reduce noise) + const sanitizedPayload = JSON.parse(JSON.stringify(forwardedJobConfig)); + + // Remove empty nested objects that may confuse the server + if (sanitizedPayload.General && Object.keys(sanitizedPayload.General).length === 0) { + delete sanitizedPayload.General; + } + if (sanitizedPayload.Dataset && Object.keys(sanitizedPayload.Dataset).length === 0) { + delete sanitizedPayload.Dataset; + } + if (sanitizedPayload.Training && Object.keys(sanitizedPayload.Training).length === 0) { + delete sanitizedPayload.Training; + } + + // Normalize file name using uploadedFileIdMap + if (sanitizedPayload.training_file && typeof sanitizedPayload.training_file === 'string') { + const originalFilename = sanitizedPayload.training_file; + + // Try to decode first in case it's URL-encoded + let lookupKey = originalFilename; + try { + const decoded = decodeURIComponent(originalFilename); + lookupKey = decoded; + } catch (e) { + // ignore decode errors + } + + // Check if we have a stored mapping from the upload + let stored = uploadedFileIdMap.get(lookupKey); + if (!stored && lookupKey !== originalFilename) { + // Also try the original (encoded) key + stored = uploadedFileIdMap.get(originalFilename); + } + + if (stored && stored.rawFilename) { + sanitizedPayload.training_file = stored.rawFilename; + } + } + + // >>> NEW: sanitize fragile fields (padding, truncation, preprocessor, DDP, etc.) + sanitizeFineTuningPayload(sanitizedPayload); + + // Try a sequence of attempts to accommodate naming/encoding/id differences. + const attemptPost = async (payload: any, label = 'attempt') => { + try { + // eslint-disable-next-line no-console + console.debug(`finetuningService.createFineTuningJob - ${label} payload:`, payload); + const resp = await axiosClient.post('/v1/fine_tuning/jobs', payload); + // eslint-disable-next-line no-console + console.debug( + `finetuningService.createFineTuningJob - ${label} response:`, + typeof resp?.data === 'string' ? resp.data : JSON.stringify(resp?.data) + ); + return resp; + } catch (err: any) { + // Log detailed info for debugging try { - const payloadTask = sanitizedPayload?.General?.task || sanitizedPayload?.task - if (payloadTask && !respData.task) { - // attach task so persistJobToDb stores it - try { respData.task = payloadTask } catch (e) { /* ignore */ } - } - } catch (e) { - // ignore + // eslint-disable-next-line no-console + console.error(`finetuningService.createFineTuningJob - ${label} failed`, { + message: err?.message, + status: err?.response?.status, + responseData: + typeof err?.response?.data === 'string' + ? err.response.data + : JSON.stringify(err?.response?.data), + payload, + }); + } catch (logErr) { + // ignore logging errors } + throw err; + } + }; - // Persist to local DB + // Send the sanitized payload + const resp = await attemptPost(sanitizedPayload, 'final'); + const respData = resp.data; + + // If the external service didn't echo back the task, preserve task from our sanitized payload + try { + const payloadTask = sanitizedPayload?.General?.task || sanitizedPayload?.task; + if (payloadTask && !respData.task) { + // attach task so persistJobToDb stores it try { - await persistJobToDb(respData) + respData.task = payloadTask; } catch (e) { - // ignore + /* ignore */ } - return respData - } catch (error: any) { - // Log error details from external service if available for debugging - try { - // eslint-disable-next-line no-console - console.error('finetuningService.createFineTuningJob - axios error:', { - message: error.message, - responseData: error.response ? (typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data)) : undefined, - status: error.response ? error.response.status : undefined, - headers: error.response ? error.response.headers : undefined - }) - } catch (logErr) { - // ignore logging errors - } - throw new InternalFlowiseError( - StatusCodes.INTERNAL_SERVER_ERROR, - `Error: finetuningService.createFineTuningJob - ${getErrorMessage(error)}` - ) + } + } catch (e) { + // ignore } -} + + // Persist to local DB + try { + await persistJobToDb(respData); + } catch (e) { + // ignore + } + return respData; + } catch (error: any) { + // Log error details from external service if available for debugging + try { + // eslint-disable-next-line no-console + console.error('finetuningService.createFineTuningJob - axios error:', { + message: error.message, + responseData: error.response + ? typeof error.response.data === 'string' + ? error.response.data + : JSON.stringify(error.response.data) + : undefined, + status: error.response ? error.response.status : undefined, + headers: error.response ? error.response.headers : undefined, + }); + } catch (logErr) { + // ignore logging errors + } + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: finetuningService.createFineTuningJob - ${getErrorMessage(error)}` + ); + } +}; + /** * List all fine-tuning jobs diff --git a/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobsTable.jsx b/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobsTable.jsx index 7137603..998d34b 100644 --- a/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobsTable.jsx +++ b/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobsTable.jsx @@ -124,6 +124,7 @@ const FinetuningJobsTable = ({ data, isLoading = false, onRefresh = null, filter const [logsOpen, setLogsOpen] = useState(false) const [logsData, setLogsData] = useState('') const [logsLoading, setLogsLoading] = useState(false) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const logsContainerRef = useRef(null) // Auto-refresh logs every 3 seconds when logs dialog is open @@ -687,18 +688,7 @@ const FinetuningJobsTable = ({ data, isLoading = false, onRefresh = null, filter { if (!selectedJob) return - if (!window.confirm('Are you sure you want to delete this job?')) return - setActionLoading(true) - try { - await finetuningApi.deleteJob(selectedJob.id) - handleMenuClose() - if (onRefresh) onRefresh() - } catch (error) { - console.error('Error deleting job:', error) - alert('Failed to delete job: ' + (error.message || 'Unknown error')) - } finally { - setActionLoading(false) - } + setDeleteConfirmOpen(true) }} disabled={actionLoading} > @@ -768,6 +758,43 @@ const FinetuningJobsTable = ({ data, isLoading = false, onRefresh = null, filter + + {/* Delete Confirmation Dialog */} + setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> + Delete Job + + + Are you sure you want to delete this job? This action cannot be undone. + + + + + + + ) } diff --git a/studio-frontend/packages/ui/src/views/opeaflows/index.jsx b/studio-frontend/packages/ui/src/views/opeaflows/index.jsx index 5e7e28f..b3c95db 100644 --- a/studio-frontend/packages/ui/src/views/opeaflows/index.jsx +++ b/studio-frontend/packages/ui/src/views/opeaflows/index.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' // material-ui -import { Box, Skeleton, Stack, Input, Typography } from '@mui/material' +import { Box, Skeleton, Stack, Input, Typography, Alert } from '@mui/material' import { useTheme } from '@mui/material/styles' // project imports @@ -41,6 +41,7 @@ const Opeaflows = () => { const [isLoading, setLoading] = useState(true) const [error, setError] = useState(null) + const [importError, setImportError] = useState(null) const [images, setImages] = useState({}) const [search, setSearch] = useState('') const [loginDialogOpen, setLoginDialogOpen] = useState(false) @@ -95,11 +96,20 @@ const Opeaflows = () => { const importSamples = () => { setLoading(true); - chatflowsApi.importSampleChatflowsbyUserId(keycloak.tokenParsed.email).then(() => { - getAllOpeaflowsApi.request(); - }).catch(() => { - setLoading(false); - }); + setImportError(null); + chatflowsApi.importSampleChatflowsbyUserId(keycloak.tokenParsed.email) + .then(() => { + getAllOpeaflowsApi.request(); + setImportError(null); + }) + .catch((error) => { + setLoading(false); + const errorMessage = error?.response?.data?.message || + error?.message || + 'Failed to import sample workflows. Please try again later.'; + setImportError(errorMessage); + console.error('Error importing sample chatflows:', error); + }); } const goToCanvas = (selectedChatflow) => { @@ -161,6 +171,11 @@ const Opeaflows = () => { ) : ( + {importError && ( + setImportError(null)} sx={{ mb: 2 }}> + Import Failed: {importError} + + )} { await page.locator('button').nth(5).click(); await page.getByRole('menuitem', { name: 'Delete Job' }).click(); - + await page.getByRole('button', { name: 'Delete', exact: true }).click(); }); \ No newline at end of file diff --git a/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts b/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts index c9b2a20..0c8ffe1 100644 --- a/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts +++ b/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts @@ -64,5 +64,5 @@ test('006_test_funetuning_embedding', async ({ browser, baseURL }) => { await page.locator('button').nth(5).click(); await page.getByRole('menuitem', { name: 'Delete Job' }).click(); - + await page.getByRole('button', { name: 'Delete', exact: true }).click(); }); \ No newline at end of file diff --git a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts b/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts index 5ff7d2b..c0b0105 100644 --- a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts +++ b/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts @@ -64,6 +64,5 @@ test('007_test_funetuning_reasoning', async ({ browser, baseURL }) => { await page.locator('button').nth(5).click(); await page.getByRole('menuitem', { name: 'Delete Job' }).click(); - - + await page.getByRole('button', { name: 'Delete', exact: true }).click(); }); \ No newline at end of file From ddc903d2182fc2c4d0b990547c314326337b6e65 Mon Sep 17 00:00:00 2001 From: wwanarif Date: Mon, 8 Dec 2025 18:34:15 +0800 Subject: [PATCH 4/9] fix validation issues Signed-off-by: wwanarif --- studio-backend/app/requirements.txt | 1 + studio-backend/app/routers/sandbox_router.py | 2 ++ studio-backend/tests/requirements.txt | 3 ++- .../studio-e2e/007_test_funetuning_reasoning.spec.ts | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/studio-backend/app/requirements.txt b/studio-backend/app/requirements.txt index bb1ba66..0828047 100644 --- a/studio-backend/app/requirements.txt +++ b/studio-backend/app/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.115.4 uvicorn==0.30.6 kubernetes==30.1.0 requests==2.32.3 +urllib3==2.0.0 pydantic==1.10.18 starlette==0.41.2 websockets==10.3 diff --git a/studio-backend/app/routers/sandbox_router.py b/studio-backend/app/routers/sandbox_router.py index dddd905..5bdf4d7 100644 --- a/studio-backend/app/routers/sandbox_router.py +++ b/studio-backend/app/routers/sandbox_router.py @@ -19,6 +19,8 @@ async def deploy_sandbox(request: PipelineFlow): try: response = deploy_manifest_in_namespace(core_v1_api, apps_v1_api, json.loads(workflow_info.export_to_json())) except Exception as e: + import traceback + traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) return response diff --git a/studio-backend/tests/requirements.txt b/studio-backend/tests/requirements.txt index a329e9a..cda1796 100644 --- a/studio-backend/tests/requirements.txt +++ b/studio-backend/tests/requirements.txt @@ -2,4 +2,5 @@ pytest==8.3.3 fastapi==0.115.0 httpx==0.27.2 kubernetes==30.1.0 -pydantic==1.10.18 \ No newline at end of file +pydantic==1.10.18 +urllib3==2.0.0 \ No newline at end of file diff --git a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts b/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts index c0b0105..53a90fa 100644 --- a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts +++ b/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts @@ -60,7 +60,7 @@ test('007_test_funetuning_reasoning', async ({ browser, baseURL }) => { await page.waitForTimeout(20000); await expect(page.getByRole('cell', { name: 'running' })).toHaveText('running'); await expect(page.locator('div').filter({ hasText: 'Fine-tuning JobsCreate New' }).nth(3)).toContainText('reasoning'); - await waitForStatusText(page, 'MuiChip-label MuiChip-labelSmall css-cxrmjv', 'succeeded', 20, 60000); + await waitForStatusText(page, 'td.MuiTableCell-root div.MuiChip-root', 'succeeded', 20, 60000); await page.locator('button').nth(5).click(); await page.getByRole('menuitem', { name: 'Delete Job' }).click(); From 44ceaff87c0ca2d91ceb8c83c03b3d93b7236bdc Mon Sep 17 00:00:00 2001 From: wwanarif Date: Tue, 9 Dec 2025 13:34:55 +0000 Subject: [PATCH 5/9] optimizing finetuning downloads Signed-off-by: wwanarif --- .../manifests/studio-manifest.yaml | 2 +- .../src/controllers/finetuning/index.ts | 44 +++- .../server/src/services/finetuning/index.ts | 222 ++++++++++-------- .../server/src/ws/finetuningDownload.ts | 12 +- 4 files changed, 173 insertions(+), 107 deletions(-) diff --git a/setup-scripts/setup-genai-studio/manifests/studio-manifest.yaml b/setup-scripts/setup-genai-studio/manifests/studio-manifest.yaml index 4b5440e..d2e2f5b 100644 --- a/setup-scripts/setup-genai-studio/manifests/studio-manifest.yaml +++ b/setup-scripts/setup-genai-studio/manifests/studio-manifest.yaml @@ -426,7 +426,7 @@ spec: storageClassName: local-path resources: requests: - storage: 1Gi + storage: 30Gi --- apiVersion: v1 kind: Service diff --git a/studio-frontend/packages/server/src/controllers/finetuning/index.ts b/studio-frontend/packages/server/src/controllers/finetuning/index.ts index add0c5a..0198266 100644 --- a/studio-frontend/packages/server/src/controllers/finetuning/index.ts +++ b/studio-frontend/packages/server/src/controllers/finetuning/index.ts @@ -3,6 +3,9 @@ import { StatusCodes } from 'http-status-codes' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import finetuningService from '../../services/finetuning' +// Declare timer globals for Node.js +declare function setTimeout(cb: (...args: any[]) => void, ms?: number): any + /** * Upload a training file * POST /api/v1/finetuning/files @@ -154,6 +157,7 @@ const getFineTuningJobLogs = async (req: Request, res: Response, next: NextFunct /** * Download fine-tuning job output as a zip file * GET /api/v1/finetuning/download-ft/:jobId + * Creates zip, streams it to client, then deletes the zip file after download completes */ const downloadFineTuningOutput = async (req: Request, res: Response, next: NextFunction) => { try { @@ -166,31 +170,63 @@ const downloadFineTuningOutput = async (req: Request, res: Response, next: NextF ) } - // Get the zip file path (creates if needed, but returns immediately if already exists) + // Get the zip file path from service const filePath = await finetuningService.downloadFineTuningOutput(jobId) + if (!filePath) { throw new InternalFlowiseError( StatusCodes.NOT_FOUND, - `Error: finetuningController.downloadFineTuningOutput - output not found for job: ${jobId}` + `Error: finetuningController.downloadFineTuningOutput - zip file not found for job: ${jobId}. Please request download via WebSocket first.` ) } + const fs = require('fs') + // Set response headers for file download const fileName = `${jobId}-output.zip` res.setHeader('Content-Type', 'application/zip') res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`) // Stream the file - const fs = require('fs') const fileStream = fs.createReadStream(filePath) + + // Log when stream opens + fileStream.on('open', () => { + console.debug(`finetuningController.downloadFineTuningOutput - starting to stream: ${filePath}`) + }) + + // Delete zip file after response fully finishes (browser completes download) + res.on('finish', () => { + setTimeout(() => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + console.debug(`finetuningController.downloadFineTuningOutput - deleted zip after download complete: ${filePath}`) + } + } catch (deleteErr: any) { + console.warn(`finetuningController.downloadFineTuningOutput - failed to delete zip: ${deleteErr?.message || deleteErr}`) + } + }, 100) + }) + fileStream.on('error', (err: any) => { - console.error('Error streaming fine-tuning output file:', err) + console.error('finetuningController.downloadFineTuningOutput - error streaming file:', err) + // Try to delete zip on error too + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + console.debug(`finetuningController.downloadFineTuningOutput - deleted zip after error: ${filePath}`) + } + } catch (deleteErr: any) { + console.warn(`finetuningController.downloadFineTuningOutput - failed to delete zip on error: ${deleteErr?.message || deleteErr}`) + } if (!res.headersSent) { res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: 'Error streaming fine-tuning output file' }) } }) + fileStream.pipe(res) } catch (error) { next(error) diff --git a/studio-frontend/packages/server/src/services/finetuning/index.ts b/studio-frontend/packages/server/src/services/finetuning/index.ts index f52ea56..da74ff9 100644 --- a/studio-frontend/packages/server/src/services/finetuning/index.ts +++ b/studio-frontend/packages/server/src/services/finetuning/index.ts @@ -13,6 +13,10 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { FineTuningJob } from '../../database/entities/FineTuningJob' import logger from '../../utils/logger' +// Declare timer globals for Node.js +declare function setTimeout(cb: (...args: any[]) => void, ms?: number): any +declare function clearTimeout(id: any): void + const execAsync = promisify(exec) const FINETUNING_SERVICE_URL = process.env.FINETUNING_HOST ? `http://${process.env.FINETUNING_HOST}:8015` : 'undefined' @@ -36,93 +40,6 @@ const axiosClient: AxiosInstance = axios.create({ // In-memory mapping: filename (raw and decoded) -> { id, rawFilename } const uploadedFileIdMap: Map = new Map() -/** - * Helper function to zip a fine-tuning job output directory - * Checks if zip already exists and is up-to-date before creating a new one - * @param outputDir - Full path to the output directory for the job - * @param jobId - ID of the fine-tuning job - * @returns Path to the zipped file or null if failed - */ -const ensureFineTuningOutputZip = async (outputDir: string, jobId: string): Promise => { - try { - // eslint-disable-next-line no-console - console.debug(`finetuningService.ensureFineTuningOutputZip - processing output for job: ${jobId}`) - - // Validate output directory exists - if (!fs.existsSync(outputDir)) { - // eslint-disable-next-line no-console - console.warn(`finetuningService.ensureFineTuningOutputZip - output directory not found: ${outputDir}`) - return null - } - - const zipFilePath = `${outputDir}.zip` - const outputStats = fs.statSync(outputDir) - - // Check if zip exists and is up-to-date - if (fs.existsSync(zipFilePath)) { - const zipStats = fs.statSync(zipFilePath) - // If zip is newer than the output directory, skip re-zipping - if (zipStats.mtimeMs > outputStats.mtimeMs) { - // eslint-disable-next-line no-console - console.debug(`finetuningService.ensureFineTuningOutputZip - zip already up-to-date: ${zipFilePath}`) - return zipFilePath - } - // Remove outdated zip - try { - fs.unlinkSync(zipFilePath) - // eslint-disable-next-line no-console - console.debug(`finetuningService.ensureFineTuningOutputZip - removed outdated zip: ${zipFilePath}`) - } catch (e) { - // eslint-disable-next-line no-console - console.warn(`finetuningService.ensureFineTuningOutputZip - failed to remove outdated zip: ${e}`) - } - } - - // Create zip file using archiver (standard ZIP format compatible with Windows) - // eslint-disable-next-line no-console - console.debug(`finetuningService.ensureFineTuningOutputZip - starting to zip output for job ${jobId}`) - try { - return await new Promise((resolve, reject) => { - const output = fs.createWriteStream(zipFilePath) - const archive = archiver('zip', { - zlib: { level: 6 } // compression level - }) - - output.on('close', () => { - // eslint-disable-next-line no-console - console.debug(`finetuningService.ensureFineTuningOutputZip - zip created successfully for job ${jobId}: ${zipFilePath} (${archive.pointer()} bytes)`) - resolve(zipFilePath) - }) - - output.on('error', (err: any) => { - // eslint-disable-next-line no-console - console.error(`finetuningService.ensureFineTuningOutputZip - write stream error: ${err?.message || err}`) - reject(err) - }) - - archive.on('error', (err: any) => { - // eslint-disable-next-line no-console - console.error(`finetuningService.ensureFineTuningOutputZip - archiver error: ${err?.message || err}`) - reject(err) - }) - - archive.pipe(output) - // Add the entire directory to the archive with basename as root - archive.directory(outputDir, path.basename(outputDir)) - archive.finalize() - }) - } catch (execErr: any) { - // eslint-disable-next-line no-console - console.error(`finetuningService.ensureFineTuningOutputZip - archiver failed for job ${jobId}: ${execErr?.message || execErr}`) - return null - } - } catch (error: any) { - // eslint-disable-next-line no-console - console.error(`finetuningService.ensureFineTuningOutputZip - error: ${error?.message || error}`) - return null - } -} - /** * Upload a training file to the finetuning service */ @@ -783,12 +700,12 @@ const deleteFineTuningJob = async (fineTuningJobId: string) => { } /** - * Download fine-tuning job output as a zip file - * Creates zip if needed, or returns existing zip immediately + * Prepare fine-tuning job output as a zip file for download + * Called by WebSocket to create and cache the zip * @param jobId - ID of the fine-tuning job * @returns Path to the zipped file or null if not found */ -const downloadFineTuningOutput = async (jobId: string): Promise => { +const prepareFineTuningOutputZip = async (jobId: string): Promise => { try { if (!jobId) { throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Job ID is required') @@ -816,18 +733,82 @@ const downloadFineTuningOutput = async (jobId: string): Promise = throw new InternalFlowiseError(StatusCodes.FORBIDDEN, 'Invalid job output path') } - // Ensure the output is zipped (returns immediately if zip is up-to-date) - const finalZipPath = await ensureFineTuningOutputZip(jobOutputDir, jobId) - if (!finalZipPath) { - throw new InternalFlowiseError( - StatusCodes.INTERNAL_SERVER_ERROR, - `Failed to create zip for job ${jobId}` - ) - } + const zipFilePath = `${jobOutputDir}.zip` + // Create zip file using archiver // eslint-disable-next-line no-console - console.debug(`finetuningService.downloadFineTuningOutput - file ready for download: ${finalZipPath}`) - return finalZipPath + console.debug(`finetuningService.downloadFineTuningOutput - creating zip for job ${jobId}`) + + // Log directory contents for diagnostics + try { + const dirContents = fs.readdirSync(jobOutputDir) + // eslint-disable-next-line no-console + console.debug(`finetuningService.downloadFineTuningOutput - output directory contains ${dirContents.length} items: ${dirContents.slice(0, 10).join(', ')}${dirContents.length > 10 ? '...' : ''}`) + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`finetuningService.downloadFineTuningOutput - could not list directory: ${e}`) + } + + try { + return await new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipFilePath) + const archive = archiver('zip', { + zlib: { level: 0 } // no compression for speed + }) + + const zipTimeoutMs = 30 * 60 * 1000 // 30 minutes + let resolved = false + + const timeoutHandle = setTimeout(() => { + if (!resolved) { + resolved = true + // eslint-disable-next-line no-console + console.error(`finetuningService.downloadFineTuningOutput - archiver timeout for job ${jobId}`) + try { output.destroy() } catch (e) {} + try { archive.destroy() } catch (e) {} + reject(new Error('Archiver timeout')) + } + }, zipTimeoutMs) + + output.on('close', () => { + if (!resolved) { + resolved = true + clearTimeout(timeoutHandle) + // eslint-disable-next-line no-console + console.debug(`finetuningService.downloadFineTuningOutput - zip created: ${zipFilePath}`) + resolve(zipFilePath) + } + }) + + output.on('error', (err: any) => { + if (!resolved) { + resolved = true + clearTimeout(timeoutHandle) + // eslint-disable-next-line no-console + console.error(`finetuningService.downloadFineTuningOutput - write stream error: ${err?.message || err}`) + reject(err) + } + }) + + archive.on('error', (err: any) => { + if (!resolved) { + resolved = true + clearTimeout(timeoutHandle) + // eslint-disable-next-line no-console + console.error(`finetuningService.downloadFineTuningOutput - archiver error: ${err?.message || err}`) + reject(err) + } + }) + + archive.pipe(output) + archive.directory(jobOutputDir, path.basename(jobOutputDir)) + archive.finalize() + }) + } catch (archiverErr: any) { + // eslint-disable-next-line no-console + console.error(`finetuningService.downloadFineTuningOutput - archiver failed for job ${jobId}: ${archiverErr?.message || archiverErr}`) + return null + } } catch (error: any) { if (error instanceof InternalFlowiseError) { throw error @@ -841,6 +822,46 @@ const downloadFineTuningOutput = async (jobId: string): Promise = } } +/** + * Download fine-tuning job output - HTTP endpoint + * Returns path to cached ZIP file + * @param jobId - ID of the fine-tuning job + * @returns Path to the zipped file or null if not found + */ +const downloadFineTuningOutput = async (jobId: string): Promise => { + try { + if (!jobId) { + return null + } + + const OUTPUT_BASE_DIR = '/tmp/finetuning/output' + const zipFilePath = `${OUTPUT_BASE_DIR}/${jobId}.zip` + + // Check if zip file exists + if (fs.existsSync(zipFilePath)) { + try { + const stat = fs.statSync(zipFilePath) + if (stat.size > 0) { + // eslint-disable-next-line no-console + console.debug(`finetuningService.downloadFineTuningOutput - returning cached zip: ${zipFilePath}`) + return zipFilePath + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`finetuningService.downloadFineTuningOutput - could not stat zip file: ${e}`) + } + } + + // eslint-disable-next-line no-console + console.warn(`finetuningService.downloadFineTuningOutput - zip file not found: ${zipFilePath}`) + return null + } catch (error: any) { + // eslint-disable-next-line no-console + console.error(`finetuningService.downloadFineTuningOutput - error: ${error?.message || error}`) + return null + } +} + /** * Get logs for a fine-tuning job by querying the Ray head node HTTP API. * It will call: http:///api/jobs//logs @@ -929,5 +950,6 @@ export default { cancelFineTuningJob, deleteFineTuningJob, getFineTuningJobLogs, + prepareFineTuningOutputZip, downloadFineTuningOutput } diff --git a/studio-frontend/packages/server/src/ws/finetuningDownload.ts b/studio-frontend/packages/server/src/ws/finetuningDownload.ts index 484d2e8..eebd900 100644 --- a/studio-frontend/packages/server/src/ws/finetuningDownload.ts +++ b/studio-frontend/packages/server/src/ws/finetuningDownload.ts @@ -99,10 +99,18 @@ export const setupFineTuningDownloadHandlers = (io: Server) => { // Kick off the async preparation and store the promise so others can join task.status = 'zipping' + + // Emit progress update to socket immediately + socket.emit('download-finetuning-progress', { + jobId, + status: 'zipping', + message: 'Creating zip archive (this may take a few minutes)...' + }) + task.promise = (async () => { try { - // Call the service to prepare the zip file (returns path) - const zipFilePath = await finetuningService.downloadFineTuningOutput(jobId) + // Call the service to prepare the zip file + const zipFilePath = await finetuningService.prepareFineTuningOutputZip(jobId) if (!zipFilePath) { task.status = 'error' From 5c57b06d277dac6b3ed712c2d2f5cc9f085075bf Mon Sep 17 00:00:00 2001 From: wwanarif Date: Wed, 10 Dec 2025 02:41:40 +0000 Subject: [PATCH 6/9] implement test name changes Signed-off-by: wwanarif --- ...tuning_rerank.spec.ts => 005_test_finetuning_rerank.spec.ts} | 2 +- ..._embedding.spec.ts => 006_test_finetuning_embedding.spec.ts} | 2 +- ..._reasoning.spec.ts => 007_test_finetuning_reasoning.spec.ts} | 2 +- tests/playwright/utils.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename tests/playwright/studio-e2e/{005_test_funetuning_rerank.spec.ts => 005_test_finetuning_rerank.spec.ts} (98%) rename tests/playwright/studio-e2e/{006_test_funetuning_embedding.spec.ts => 006_test_finetuning_embedding.spec.ts} (98%) rename tests/playwright/studio-e2e/{007_test_funetuning_reasoning.spec.ts => 007_test_finetuning_reasoning.spec.ts} (98%) diff --git a/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts b/tests/playwright/studio-e2e/005_test_finetuning_rerank.spec.ts similarity index 98% rename from tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts rename to tests/playwright/studio-e2e/005_test_finetuning_rerank.spec.ts index 5feea51..9f934ad 100644 --- a/tests/playwright/studio-e2e/005_test_funetuning_rerank.spec.ts +++ b/tests/playwright/studio-e2e/005_test_finetuning_rerank.spec.ts @@ -28,7 +28,7 @@ async function setupResponseListener(page, apiResponse) { }); } -test('005_test_funetuning_rerank', async ({ browser, baseURL }) => { +test('005_test_finetuning_rerank', async ({ browser, baseURL }) => { test.setTimeout(1200000); let apiResponse = { value: '' }; const context = await browser.newContext({ diff --git a/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts b/tests/playwright/studio-e2e/006_test_finetuning_embedding.spec.ts similarity index 98% rename from tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts rename to tests/playwright/studio-e2e/006_test_finetuning_embedding.spec.ts index 0c8ffe1..7886c38 100644 --- a/tests/playwright/studio-e2e/006_test_funetuning_embedding.spec.ts +++ b/tests/playwright/studio-e2e/006_test_finetuning_embedding.spec.ts @@ -28,7 +28,7 @@ async function setupResponseListener(page, apiResponse) { }); } -test('006_test_funetuning_embedding', async ({ browser, baseURL }) => { +test('006_test_finetuning_embedding', async ({ browser, baseURL }) => { test.setTimeout(1200000); let apiResponse = { value: '' }; const context = await browser.newContext({ diff --git a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts b/tests/playwright/studio-e2e/007_test_finetuning_reasoning.spec.ts similarity index 98% rename from tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts rename to tests/playwright/studio-e2e/007_test_finetuning_reasoning.spec.ts index 53a90fa..72472ba 100644 --- a/tests/playwright/studio-e2e/007_test_funetuning_reasoning.spec.ts +++ b/tests/playwright/studio-e2e/007_test_finetuning_reasoning.spec.ts @@ -28,7 +28,7 @@ async function setupResponseListener(page, apiResponse) { }); } -test('007_test_funetuning_reasoning', async ({ browser, baseURL }) => { +test('007_test_finetuning_reasoning', async ({ browser, baseURL }) => { test.setTimeout(1200000); let apiResponse = { value: '' }; const context = await browser.newContext({ diff --git a/tests/playwright/utils.ts b/tests/playwright/utils.ts index 92bd6ce..f042ff9 100644 --- a/tests/playwright/utils.ts +++ b/tests/playwright/utils.ts @@ -4,7 +4,7 @@ export async function waitForStatusText(page: any, selector: string, statusText: for (let i = 0; i < maxAttempts; i++) { try { const text = await page.locator(selector).first().innerText(); - if (text === 'Error' ||text === 'failed') { + if (text === 'Error' || text === 'failed') { throw new Error(`Encountered unwanted status text "Error" in element "${selector}"`); } await expect(page.locator(selector).first()).toHaveText(statusText, { timeout: intervalTimeout }); From 34da16b35a3f178b3697a00b6200e210252518c1 Mon Sep 17 00:00:00 2001 From: wwanarif Date: Wed, 10 Dec 2025 11:18:56 +0000 Subject: [PATCH 7/9] fix race condition and update some UI Signed-off-by: wwanarif --- .../src/controllers/finetuning/index.ts | 39 ++++++++----------- .../views/finetuning/FinetuningJobModal.jsx | 12 +++--- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/studio-frontend/packages/server/src/controllers/finetuning/index.ts b/studio-frontend/packages/server/src/controllers/finetuning/index.ts index 0198266..a8e6dcc 100644 --- a/studio-frontend/packages/server/src/controllers/finetuning/index.ts +++ b/studio-frontend/packages/server/src/controllers/finetuning/index.ts @@ -182,50 +182,43 @@ const downloadFineTuningOutput = async (req: Request, res: Response, next: NextF const fs = require('fs') + // Get file stats for Content-Length header (enables browser progress bar) + const fileStats = fs.statSync(filePath) + const fileSize = fileStats.size + // Set response headers for file download const fileName = `${jobId}-output.zip` res.setHeader('Content-Type', 'application/zip') res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`) + res.setHeader('Content-Length', fileSize) // Stream the file const fileStream = fs.createReadStream(filePath) // Log when stream opens fileStream.on('open', () => { - console.debug(`finetuningController.downloadFineTuningOutput - starting to stream: ${filePath}`) + console.debug(`finetuningController.downloadFineTuningOutput - starting to stream: ${filePath} (${fileSize} bytes)`) }) - - // Delete zip file after response fully finishes (browser completes download) - res.on('finish', () => { - setTimeout(() => { - try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) - console.debug(`finetuningController.downloadFineTuningOutput - deleted zip after download complete: ${filePath}`) - } - } catch (deleteErr: any) { - console.warn(`finetuningController.downloadFineTuningOutput - failed to delete zip: ${deleteErr?.message || deleteErr}`) - } - }, 100) + + // Log when the file stream closes (end of stream on server side) + fileStream.on('close', () => { + console.debug(`finetuningController.downloadFineTuningOutput - end stream: ${filePath}`) }) + // Multiple users can download the same ZIP simultaneously fileStream.on('error', (err: any) => { console.error('finetuningController.downloadFineTuningOutput - error streaming file:', err) - // Try to delete zip on error too - try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) - console.debug(`finetuningController.downloadFineTuningOutput - deleted zip after error: ${filePath}`) - } - } catch (deleteErr: any) { - console.warn(`finetuningController.downloadFineTuningOutput - failed to delete zip on error: ${deleteErr?.message || deleteErr}`) - } if (!res.headersSent) { res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: 'Error streaming fine-tuning output file' }) } }) + + // Log when HTTP response finishes sending bytes to client + res.on('finish', () => { + console.debug(`finetuningController.downloadFineTuningOutput - response finished streaming: ${filePath}`) + }) fileStream.pipe(res) } catch (error) { diff --git a/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobModal.jsx b/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobModal.jsx index e9c9b13..03653df 100644 --- a/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobModal.jsx +++ b/studio-frontend/packages/ui/src/views/finetuning/FinetuningJobModal.jsx @@ -779,7 +779,7 @@ const FinetuningJobModal = ({ open, onClose, onJobCreated }) => { value={formData.dataset.padding_side} onChange={(e) => handleConfigChange('dataset', 'padding_side', e.target.value)} size="small" - sx={{ width: '100%', maxWidth: 240 }} + sx={{ width: '100%' }} /> @@ -789,7 +789,7 @@ const FinetuningJobModal = ({ open, onClose, onJobCreated }) => { value={formData.dataset.truncation_side} onChange={(e) => handleConfigChange('dataset', 'truncation_side', e.target.value)} size="small" - sx={{ width: '100%', maxWidth: 240 }} + sx={{ width: '100%' }} /> @@ -799,7 +799,7 @@ const FinetuningJobModal = ({ open, onClose, onJobCreated }) => { value={String(formData.dataset.padding)} onChange={(e) => handleConfigChange('dataset', 'padding', e.target.value)} size="small" - sx={{ width: '100%', maxWidth: 240 }} + sx={{ width: '100%' }} /> @@ -809,7 +809,7 @@ const FinetuningJobModal = ({ open, onClose, onJobCreated }) => { value={String(formData.dataset.truncation)} onChange={(e) => handleConfigChange('dataset', 'truncation', e.target.value)} size="small" - sx={{ width: '100%', maxWidth: 240 }} + sx={{ width: '100%' }} /> @@ -819,7 +819,7 @@ const FinetuningJobModal = ({ open, onClose, onJobCreated }) => { value={String(formData.dataset.mask_input)} onChange={(e) => handleConfigChange('dataset', 'mask_input', e.target.value)} size="small" - sx={{ width: '100%', maxWidth: 240 }} + sx={{ width: '100%' }} /> @@ -829,7 +829,7 @@ const FinetuningJobModal = ({ open, onClose, onJobCreated }) => { value={String(formData.dataset.mask_response)} onChange={(e) => handleConfigChange('dataset', 'mask_response', e.target.value)} size="small" - sx={{ width: '100%', maxWidth: 240 }} + sx={{ width: '100%' }} /> From 60921a03ebaeee403b820709fb9b6ba4ee051121 Mon Sep 17 00:00:00 2001 From: wwanarif Date: Sat, 13 Dec 2025 12:22:08 +0800 Subject: [PATCH 8/9] cve fixes Signed-off-by: wwanarif --- .github/workflows/manual-docker-scan.yml | 1 + app-backend/Dockerfile | 3 +- studio-backend/app/requirements.txt | 6 +- studio-backend/tests/requirements.txt | 4 +- studio-frontend/Dockerfile | 68 +++++--- studio-frontend/package.json | 60 ++++--- studio-frontend/packages/server/package.json | 27 +-- .../packages/server/src/NodesPool.ts | 2 +- .../packages/server/src/commands/start.ts | 4 +- .../src/controllers/get-upload-file/index.ts | 9 +- .../controllers/openai-assistants/index.ts | 3 +- .../server/src/routes/apikey/index.ts | 2 +- .../server/src/routes/assistants/index.ts | 2 +- .../server/src/routes/chat-messages/index.ts | 2 +- .../src/routes/chatflows-sandbox/index.ts | 2 +- .../src/routes/chatflows-streaming/index.ts | 2 +- .../src/routes/chatflows-uploads/index.ts | 2 +- .../server/src/routes/chatflows/index.ts | 2 +- .../components-credentials-icon/index.ts | 2 +- .../routes/components-credentials/index.ts | 2 +- .../server/src/routes/credentials/index.ts | 2 +- .../server/src/routes/documentstore/index.ts | 2 +- .../server/src/routes/export-import/index.ts | 2 +- .../server/src/routes/feedback/index.ts | 2 +- .../server/src/routes/fetch-links/index.ts | 2 +- .../server/src/routes/finetuning/index.ts | 2 +- .../server/src/routes/flow-config/index.ts | 2 +- .../src/routes/get-upload-file/index.ts | 2 +- .../src/routes/get-upload-path/index.ts | 2 +- .../packages/server/src/routes/index.ts | 2 +- .../routes/internal-chat-messages/index.ts | 2 +- .../src/routes/internal-predictions/index.ts | 2 +- .../packages/server/src/routes/leads/index.ts | 2 +- .../server/src/routes/load-prompts/index.ts | 2 +- .../server/src/routes/marketplaces/index.ts | 2 +- .../server/src/routes/node-configs/index.ts | 2 +- .../src/routes/node-custom-functions/index.ts | 2 +- .../server/src/routes/node-icons/index.ts | 2 +- .../src/routes/node-load-methods/index.ts | 2 +- .../packages/server/src/routes/nodes/index.ts | 2 +- .../routes/openai-assistants-files/index.ts | 2 +- .../openai-assistants-vector-store/index.ts | 2 +- .../src/routes/openai-assistants/index.ts | 2 +- .../packages/server/src/routes/ping/index.ts | 2 +- .../server/src/routes/predictions/index.ts | 2 +- .../server/src/routes/prompts-lists/index.ts | 2 +- .../src/routes/public-chatbots/index.ts | 2 +- .../src/routes/public-chatflows/index.ts | 2 +- .../packages/server/src/routes/stats/index.ts | 2 +- .../packages/server/src/routes/tools/index.ts | 2 +- .../server/src/routes/upsert-history/index.ts | 2 +- .../server/src/routes/variables/index.ts | 2 +- .../server/src/routes/vectors/index.ts | 2 +- .../server/src/routes/verify/index.ts | 2 +- .../server/src/routes/versions/index.ts | 2 +- .../openai-assistants-vector-store/index.ts | 20 +-- .../src/services/openai-assistants/index.ts | 4 +- .../packages/server/src/utils/SSEStreamer.ts | 107 ++++++++++++ .../server/src/utils/buildAgentGraph.ts | 2 +- .../server/src/utils/fileRepository.ts | 8 +- studio-frontend/packages/ui/package.json | 24 ++- .../ui-component/dialog/InputHintDialog.jsx | 27 +-- .../dialog/PromptLangsmithHubDialog.jsx | 28 +-- .../dialog/ViewMessagesDialog.jsx | 90 +--------- .../markdown/MemoizedReactMarkdown.jsx | 165 +++++++++++++++++- .../ui/src/views/chatmessage/ChatMessage.jsx | 73 +------- studio-frontend/turbo.json | 5 +- 67 files changed, 480 insertions(+), 348 deletions(-) diff --git a/.github/workflows/manual-docker-scan.yml b/.github/workflows/manual-docker-scan.yml index 0f7f42e..4fa5e0c 100644 --- a/.github/workflows/manual-docker-scan.yml +++ b/.github/workflows/manual-docker-scan.yml @@ -69,6 +69,7 @@ jobs: ignore-unfixed: true vuln-type: 'os,library' severity: 'CRITICAL,HIGH' + # timeout: '10m0s' - name: Cleanup if: always() diff --git a/app-backend/Dockerfile b/app-backend/Dockerfile index ee9995b..a539919 100644 --- a/app-backend/Dockerfile +++ b/app-backend/Dockerfile @@ -26,7 +26,8 @@ RUN git clone --depth 1 https://github.com/opea-project/GenAIComps.git WORKDIR /home/user/GenAIComps RUN pip install --no-cache-dir --upgrade pip==24.3.1 setuptools==78.1.1 && \ pip install --no-cache-dir -r /home/user/GenAIComps/requirements.txt && \ - pip install --no-cache-dir --upgrade mcp==1.10.0 pillow==11.3.0 + pip install --no-cache-dir --upgrade mcp==1.23.0 pillow==11.3.0 \ + langchain-core==0.3.80 urllib3==2.6.0 starlette==0.49.1 COPY ./templates/microservices/* /home/user/templates/microservices/ COPY ./megaservice.py /home/user/megaservice.py diff --git a/studio-backend/app/requirements.txt b/studio-backend/app/requirements.txt index 0828047..fea8e19 100644 --- a/studio-backend/app/requirements.txt +++ b/studio-backend/app/requirements.txt @@ -1,10 +1,10 @@ -fastapi==0.115.4 +fastapi==0.121.0 uvicorn==0.30.6 kubernetes==30.1.0 requests==2.32.3 -urllib3==2.0.0 +urllib3==2.6.0 pydantic==1.10.18 -starlette==0.41.2 +starlette==0.49.1 websockets==10.3 clickhouse-driver==0.2.9 paramiko==3.5.1 \ No newline at end of file diff --git a/studio-backend/tests/requirements.txt b/studio-backend/tests/requirements.txt index cda1796..82a03fb 100644 --- a/studio-backend/tests/requirements.txt +++ b/studio-backend/tests/requirements.txt @@ -1,6 +1,6 @@ pytest==8.3.3 -fastapi==0.115.0 +fastapi==0.121.0 httpx==0.27.2 kubernetes==30.1.0 pydantic==1.10.18 -urllib3==2.0.0 \ No newline at end of file +urllib3==2.6.0 \ No newline at end of file diff --git a/studio-frontend/Dockerfile b/studio-frontend/Dockerfile index e59da67..828818e 100644 --- a/studio-frontend/Dockerfile +++ b/studio-frontend/Dockerfile @@ -1,4 +1,5 @@ -FROM node:20-alpine +# Build stage +FROM node:20-alpine AS builder # Accept proxy build arguments ARG http_proxy @@ -10,18 +11,51 @@ ENV http_proxy=${http_proxy} ENV https_proxy=${https_proxy} ENV no_proxy=${no_proxy} -# Install necessary packages +# Install build dependencies RUN apk update && apk upgrade && \ apk add --no-cache gcompat python3 make g++ git \ - # Needed for pdfjs-dist - build-base cairo-dev pango-dev \ - # Install Chromium - chromium && \ - # Install PNPM globally + build-base cairo-dev pango-dev && \ npm install -g pnpm@9 -# Debug step to verify git installation -RUN git --version +ENV NODE_OPTIONS=--max-old-space-size=8192 + +WORKDIR /usr/src + +# Copy package files first for better layer caching +COPY package.json pnpm-workspace.yaml turbo.json ./ +COPY packages/server/package.json ./packages/server/ +COPY packages/ui/package.json ./packages/ui/ + +# Install dependencies +RUN pnpm install + +# Copy source code +COPY . . + +# Build the app and clean up +RUN pnpm build && \ + # Prune to production dependencies only + pnpm prune --prod && \ + rm -rf node_modules/.cache && \ + rm -rf packages/*/node_modules/.cache + +# Production stage +FROM node:20-alpine + +# Accept proxy build arguments +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV http_proxy=${http_proxy} +ENV https_proxy=${https_proxy} +ENV no_proxy=${no_proxy} + +# Install only runtime dependencies with patched npm +RUN apk update && apk upgrade && \ + apk add --no-cache gcompat chromium && \ + npm install -g npm@latest pnpm@latest && \ + rm -rf /var/cache/apk/* ENV PUPPETEER_SKIP_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser @@ -29,18 +63,10 @@ ENV NODE_OPTIONS=--max-old-space-size=8192 WORKDIR /usr/src -# Copy app source -COPY . . - -# Install dependencies and build the app -RUN pnpm config set store-dir .pnpm-store && \ - pnpm install && \ - pnpm update cross-spawn@7.0.5 && \ - pnpm build && \ - pnpm remove esbuild && \ - rm -rf .pnpm-store && \ - rm -rf /root/.local/share/pnpm && \ - pnpm prune --prod +# Copy only necessary files from builder +COPY --from=builder /usr/src/package.json /usr/src/pnpm-workspace.yaml ./ +COPY --from=builder /usr/src/packages ./packages +COPY --from=builder /usr/src/node_modules ./node_modules EXPOSE 3000 diff --git a/studio-frontend/package.json b/studio-frontend/package.json index 0205332..b6751a4 100644 --- a/studio-frontend/package.json +++ b/studio-frontend/package.json @@ -1,17 +1,15 @@ { "name": "flowise", - "version": "2.1.4", + "version": "3.0.10", "private": true, "homepage": "https://flowiseai.com", "workspaces": [ - "packages/*", - "flowise", - "ui" + "packages/*" ], "scripts": { "build": "turbo run build", "build-force": "pnpm clean && turbo run build --force", - "dev": "turbo run dev --parallel", + "dev": "turbo run dev --parallel --no-cache", "start": "run-script-os", "start:windows": "cd packages/server/bin && run start", "start:default": "cd packages/server/bin && ./run start", @@ -32,7 +30,6 @@ "@babel/preset-typescript": "7.18.6", "@types/express": "^4.17.13", "@typescript-eslint/typescript-estree": "^7.13.1", - "esbuild": ">=0.25.0", "eslint": "^8.24.0", "eslint-config-prettier": "^8.3.0", "eslint-config-react-app": "^7.0.1", @@ -42,6 +39,9 @@ "eslint-plugin-react": "^7.26.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^2.0.0", + "cross-spawn": "^7.0.6", + "glob": "^10.5.0", + "tar-fs": "^3.1.1", "husky": "^8.0.1", "kill-port": "^2.0.1", "lint-staged": "^13.0.3", @@ -49,7 +49,7 @@ "pretty-quick": "^3.1.3", "rimraf": "^3.0.2", "run-script-os": "^1.1.6", - "turbo": "latest", + "turbo": "^2.3.3", "typescript": "^5.4.5" }, "pnpm": { @@ -58,8 +58,30 @@ "sqlite3" ], "overrides": { - "set-value": "^3.0.3", - "form-data": "4.0.4" + "@modelcontextprotocol/sdk": ">=1.24.0", + "axios": "1.12.0", + "body-parser": "2.0.2", + "braces": "3.0.3", + "cross-spawn": "7.0.6", + "esbuild": "0.27.1", + "form-data": "4.0.4", + "glob": "10.5.0", + "glob-parent": "6.0.2", + "http-proxy-middleware": "3.0.3", + "json5": "2.2.3", + "nth-check": "2.1.1", + "path-to-regexp": "0.1.12", + "prismjs": "1.29.0", + "rollup": "4.45.0", + "semver": "7.7.1", + "set-value": "4.1.0", + "solid-js": "1.9.7", + "tar-fs": ">=3.1.1", + "unset-value": "2.0.1", + "webpack-dev-middleware": "7.4.2", + "ws": "8.18.3", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "zod": "^3.25.0" }, "peerDependencyRules": { "ignoreMissing": [], @@ -71,22 +93,12 @@ "pnpm": ">=9" }, "resolutions": { + "@google/generative-ai": "^0.24.0", + "@grpc/grpc-js": "^1.10.10", + "@langchain/core": "0.3.61", "@qdrant/openapi-typescript-fetch": "1.2.6", - "@google/generative-ai": "^0.15.0", - "openai": "4.57.3", - "@langchain/core": "0.2.18", - "axios": "1.8.2", - "nth-check": "2.0.1", - "pdfjs-dist": "4.2.67", - "prismjs": "1.27.0", - "semver": "7.5.2", - "ws": "8.17.1", - "esbuild": ">=0.25.0", - "cross-spawn": ">=7.0.5", - "solid-js": ">=1.9.4", - "tar-fs": ">=3.0.8", - "form-data": "4.0.4", - "zod": ">=3.23.0" + "openai": "4.96.0", + "protobufjs": "7.4.0" }, "eslintIgnore": [ "**/dist", diff --git a/studio-frontend/packages/server/package.json b/studio-frontend/packages/server/package.json index fef719b..f35f64f 100644 --- a/studio-frontend/packages/server/package.json +++ b/studio-frontend/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "flowise", - "version": "2.1.4", + "version": "3.0.10", "description": "Flowiseai Server", "main": "dist/index", "types": "dist/index.d.ts", @@ -26,7 +26,7 @@ "nuke": "rimraf dist node_modules .turbo", "start:windows": "cd bin && run start", "start:default": "cd bin && ./run start", - "dev": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"pnpm start\"", + "dev": "nodemon", "oclif-dev": "run-script-os", "oclif-dev:windows": "cd bin && dev start", "oclif-dev:default": "cd bin && ./dev start", @@ -35,13 +35,14 @@ "typeorm": "typeorm-ts-node-commonjs", "typeorm:migration-generate": "pnpm typeorm migration:generate -d ./src/utils/typeormDataSource.ts", "typeorm:migration-run": "pnpm typeorm migration:run -d ./src/utils/typeormDataSource.ts", + "typeorm:migration-revert": "pnpm typeorm migration:revert -d ./src/utils/typeormDataSource.ts", "watch": "tsc --watch", "version": "oclif readme && git add README.md", "cypress:open": "cypress open", "cypress:run": "cypress run", "e2e": "start-server-and-test dev http://localhost:3000 cypress:run", "cypress:ci": "START_SERVER_AND_TEST_INSECURE=1 start-server-and-test start https-get://localhost:3000 cypress:run", - "test": "jest" + "test": "jest --runInBand --detectOpenHandles --forceExit" }, "keywords": [], "homepage": "https://flowiseai.com", @@ -54,11 +55,11 @@ }, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@oclif/core": "^1.13.10", - "@types/lodash": "^4.14.202", + "@oclif/core": "4.0.7", + "@types/lodash": "^4.17.20", "@types/uuid": "^9.0.7", "async-mutex": "^0.4.0", - "axios": "^1.8.2", + "axios": "1.12.0", "content-disposition": "0.5.4", "cors": "^2.8.5", "crypto-js": "^4.1.1", @@ -66,18 +67,20 @@ "express": "^4.17.3", "express-basic-auth": "^1.2.1", "express-rate-limit": "^6.9.0", - "flowise-components": "^2.1.4", + "flowise-components": "^3.0.8", "flowise-ui": "workspace:^", "http-errors": "^2.0.0", "http-status-codes": "^2.3.0", + "@langchain/core": "^0.2.0", + "@langchain/langgraph": "^0.0.15", "langchainhub": "^0.0.11", "lodash": "^4.17.21", "moment": "^2.29.3", "moment-timezone": "^0.5.34", "multer": "^1.4.5-lts.1", - "mysql2": "^3.9.2", + "mysql2": "^3.11.3", "form-data": "^4.0.0", - "openai": "^4.57.3", + "openai": "^4.96.0", "pg": "^8.11.1", "posthog-node": "^3.5.0", "reflect-metadata": "^0.1.13", @@ -88,18 +91,20 @@ "uuid": "^9.0.1", "winston": "^3.9.0", "https-proxy-agent": "^7.0.4", - "archiver": "^6.0.1" + "archiver": "^6.0.1", + "turndown": "^7.2.0" }, "devDependencies": { "@types/content-disposition": "0.5.8", "@types/cors": "^2.8.12", + "@types/express": "^4.17.17", "@types/crypto-js": "^4.1.1", "@types/multer": "^1.4.7", "@types/sanitize-html": "^2.9.5", "concurrently": "^7.1.0", "cypress": "^13.13.0", "nodemon": "^2.0.22", - "oclif": "^3", + "oclif": "^4.20.5", "rimraf": "^5.0.5", "run-script-os": "^1.1.6", "shx": "^0.3.3", diff --git a/studio-frontend/packages/server/src/NodesPool.ts b/studio-frontend/packages/server/src/NodesPool.ts index c433bf7..62c5595 100644 --- a/studio-frontend/packages/server/src/NodesPool.ts +++ b/studio-frontend/packages/server/src/NodesPool.ts @@ -108,7 +108,7 @@ export class NodesPool { private async getFiles(dir: string): Promise { const dirents = await promises.readdir(dir, { withFileTypes: true }) const files = await Promise.all( - dirents.map((dirent: Dirent) => { + dirents.map((dirent) => { const res = path.resolve(dir, dirent.name) return dirent.isDirectory() ? this.getFiles(res) : res }) diff --git a/studio-frontend/packages/server/src/commands/start.ts b/studio-frontend/packages/server/src/commands/start.ts index a31c37c..579053a 100644 --- a/studio-frontend/packages/server/src/commands/start.ts +++ b/studio-frontend/packages/server/src/commands/start.ts @@ -1,4 +1,4 @@ -import { Command, Flags } from '@oclif/core' +import { Command, Flags, Args } from '@oclif/core' import path from 'path' import * as Server from '../index' import * as DataSource from '../DataSource' @@ -14,7 +14,7 @@ enum EXIT_CODE { let processExitCode = EXIT_CODE.SUCCESS export default class Start extends Command { - static args = [] + static args = {} static flags = { FLOWISE_USERNAME: Flags.string(), FLOWISE_PASSWORD: Flags.string(), diff --git a/studio-frontend/packages/server/src/controllers/get-upload-file/index.ts b/studio-frontend/packages/server/src/controllers/get-upload-file/index.ts index a33b73e..09a8784 100644 --- a/studio-frontend/packages/server/src/controllers/get-upload-file/index.ts +++ b/studio-frontend/packages/server/src/controllers/get-upload-file/index.ts @@ -5,6 +5,12 @@ import { streamStorageFile } from 'flowise-components' import { StatusCodes } from 'http-status-codes' import { InternalFlowiseError } from '../../errors/internalFlowiseError' +interface AuthenticatedRequest extends Request { + user?: { + activeOrganizationId?: string + } +} + const streamUploadedFile = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.query.chatflowId || !req.query.chatId || !req.query.fileName) { @@ -13,8 +19,9 @@ const streamUploadedFile = async (req: Request, res: Response, next: NextFunctio const chatflowId = req.query.chatflowId as string const chatId = req.query.chatId as string const fileName = req.query.fileName as string + const orgId = (req as AuthenticatedRequest).user?.activeOrganizationId || '' res.setHeader('Content-Disposition', contentDisposition(fileName)) - const fileStream = await streamStorageFile(chatflowId, chatId, fileName) + const fileStream = await streamStorageFile(chatflowId, chatId, fileName, orgId) if (!fileStream) throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: streamStorageFile`) diff --git a/studio-frontend/packages/server/src/controllers/openai-assistants/index.ts b/studio-frontend/packages/server/src/controllers/openai-assistants/index.ts index 448d678..8c4175e 100644 --- a/studio-frontend/packages/server/src/controllers/openai-assistants/index.ts +++ b/studio-frontend/packages/server/src/controllers/openai-assistants/index.ts @@ -53,8 +53,9 @@ const getFileFromAssistant = async (req: Request, res: Response, next: NextFunct const chatflowId = req.body.chatflowId as string const chatId = req.body.chatId as string const fileName = req.body.fileName as string + const orgId = '' // Organization ID not required in this context res.setHeader('Content-Disposition', contentDisposition(fileName)) - const fileStream = await streamStorageFile(chatflowId, chatId, fileName) + const fileStream = await streamStorageFile(chatflowId, chatId, fileName, orgId) if (!fileStream) throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error: getFileFromAssistant`) diff --git a/studio-frontend/packages/server/src/routes/apikey/index.ts b/studio-frontend/packages/server/src/routes/apikey/index.ts index dbc043d..84b8fb4 100644 --- a/studio-frontend/packages/server/src/routes/apikey/index.ts +++ b/studio-frontend/packages/server/src/routes/apikey/index.ts @@ -1,6 +1,6 @@ import express from 'express' import apikeyController from '../../controllers/apikey' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', apikeyController.createApiKey) diff --git a/studio-frontend/packages/server/src/routes/assistants/index.ts b/studio-frontend/packages/server/src/routes/assistants/index.ts index 7b01cdd..1499855 100644 --- a/studio-frontend/packages/server/src/routes/assistants/index.ts +++ b/studio-frontend/packages/server/src/routes/assistants/index.ts @@ -1,7 +1,7 @@ import express from 'express' import assistantsController from '../../controllers/assistants' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', assistantsController.createAssistant) diff --git a/studio-frontend/packages/server/src/routes/chat-messages/index.ts b/studio-frontend/packages/server/src/routes/chat-messages/index.ts index ca90abc..e050453 100644 --- a/studio-frontend/packages/server/src/routes/chat-messages/index.ts +++ b/studio-frontend/packages/server/src/routes/chat-messages/index.ts @@ -1,6 +1,6 @@ import express from 'express' import chatMessageController from '../../controllers/chat-messages' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post(['/', '/:id'], chatMessageController.createChatMessage) diff --git a/studio-frontend/packages/server/src/routes/chatflows-sandbox/index.ts b/studio-frontend/packages/server/src/routes/chatflows-sandbox/index.ts index 1b22281..13d9735 100644 --- a/studio-frontend/packages/server/src/routes/chatflows-sandbox/index.ts +++ b/studio-frontend/packages/server/src/routes/chatflows-sandbox/index.ts @@ -1,6 +1,6 @@ import express from 'express' import chatflowsController from '../../controllers/chatflows' -const router = express.Router() +const router: express.Router = express.Router() // Deploy a chatflow to sandbox router.post(['/deploy/','/deploy/:id'], chatflowsController.deployChatflowSandbox) diff --git a/studio-frontend/packages/server/src/routes/chatflows-streaming/index.ts b/studio-frontend/packages/server/src/routes/chatflows-streaming/index.ts index cc8dc25..41ad33b 100644 --- a/studio-frontend/packages/server/src/routes/chatflows-streaming/index.ts +++ b/studio-frontend/packages/server/src/routes/chatflows-streaming/index.ts @@ -1,7 +1,7 @@ import express from 'express' import chatflowsController from '../../controllers/chatflows' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get(['/', '/:id'], chatflowsController.checkIfChatflowIsValidForStreaming) diff --git a/studio-frontend/packages/server/src/routes/chatflows-uploads/index.ts b/studio-frontend/packages/server/src/routes/chatflows-uploads/index.ts index 591718c..bcc55a8 100644 --- a/studio-frontend/packages/server/src/routes/chatflows-uploads/index.ts +++ b/studio-frontend/packages/server/src/routes/chatflows-uploads/index.ts @@ -1,7 +1,7 @@ import express from 'express' import chatflowsController from '../../controllers/chatflows' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get(['/', '/:id'], chatflowsController.checkIfChatflowIsValidForUploads) diff --git a/studio-frontend/packages/server/src/routes/chatflows/index.ts b/studio-frontend/packages/server/src/routes/chatflows/index.ts index 669aaf1..aee15e5 100644 --- a/studio-frontend/packages/server/src/routes/chatflows/index.ts +++ b/studio-frontend/packages/server/src/routes/chatflows/index.ts @@ -1,6 +1,6 @@ import express from 'express' import chatflowsController from '../../controllers/chatflows' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', chatflowsController.saveChatflow) diff --git a/studio-frontend/packages/server/src/routes/components-credentials-icon/index.ts b/studio-frontend/packages/server/src/routes/components-credentials-icon/index.ts index 50d2213..5541202 100644 --- a/studio-frontend/packages/server/src/routes/components-credentials-icon/index.ts +++ b/studio-frontend/packages/server/src/routes/components-credentials-icon/index.ts @@ -1,6 +1,6 @@ import express from 'express' import componentsCredentialsController from '../../controllers/components-credentials' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/components-credentials/index.ts b/studio-frontend/packages/server/src/routes/components-credentials/index.ts index 16aff2f..2d0ccfc 100644 --- a/studio-frontend/packages/server/src/routes/components-credentials/index.ts +++ b/studio-frontend/packages/server/src/routes/components-credentials/index.ts @@ -1,6 +1,6 @@ import express from 'express' import componentsCredentialsController from '../../controllers/components-credentials' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/', componentsCredentialsController.getAllComponentsCredentials) diff --git a/studio-frontend/packages/server/src/routes/credentials/index.ts b/studio-frontend/packages/server/src/routes/credentials/index.ts index 9f118b4..1c143b0 100644 --- a/studio-frontend/packages/server/src/routes/credentials/index.ts +++ b/studio-frontend/packages/server/src/routes/credentials/index.ts @@ -1,6 +1,6 @@ import express from 'express' import credentialsController from '../../controllers/credentials' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', credentialsController.createCredential) diff --git a/studio-frontend/packages/server/src/routes/documentstore/index.ts b/studio-frontend/packages/server/src/routes/documentstore/index.ts index 3f4cb94..ddc774a 100644 --- a/studio-frontend/packages/server/src/routes/documentstore/index.ts +++ b/studio-frontend/packages/server/src/routes/documentstore/index.ts @@ -1,6 +1,6 @@ import express from 'express' import documentStoreController from '../../controllers/documentstore' -const router = express.Router() +const router: express.Router = express.Router() /** Document Store Routes */ // Create document store diff --git a/studio-frontend/packages/server/src/routes/export-import/index.ts b/studio-frontend/packages/server/src/routes/export-import/index.ts index 40c3930..ac6ac00 100644 --- a/studio-frontend/packages/server/src/routes/export-import/index.ts +++ b/studio-frontend/packages/server/src/routes/export-import/index.ts @@ -1,6 +1,6 @@ import express from 'express' import exportImportController from '../../controllers/export-import' -const router = express.Router() +const router: express.Router = express.Router() router.post('/export', exportImportController.exportData) diff --git a/studio-frontend/packages/server/src/routes/feedback/index.ts b/studio-frontend/packages/server/src/routes/feedback/index.ts index bcec7c7..b642535 100644 --- a/studio-frontend/packages/server/src/routes/feedback/index.ts +++ b/studio-frontend/packages/server/src/routes/feedback/index.ts @@ -1,6 +1,6 @@ import express from 'express' import feedbackController from '../../controllers/feedback' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post(['/', '/:id'], feedbackController.createChatMessageFeedbackForChatflow) diff --git a/studio-frontend/packages/server/src/routes/fetch-links/index.ts b/studio-frontend/packages/server/src/routes/fetch-links/index.ts index a02abd5..462296a 100644 --- a/studio-frontend/packages/server/src/routes/fetch-links/index.ts +++ b/studio-frontend/packages/server/src/routes/fetch-links/index.ts @@ -1,6 +1,6 @@ import express from 'express' import fetchLinksController from '../../controllers/fetch-links' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/', fetchLinksController.getAllLinks) diff --git a/studio-frontend/packages/server/src/routes/finetuning/index.ts b/studio-frontend/packages/server/src/routes/finetuning/index.ts index 35c6114..27c68a5 100644 --- a/studio-frontend/packages/server/src/routes/finetuning/index.ts +++ b/studio-frontend/packages/server/src/routes/finetuning/index.ts @@ -2,7 +2,7 @@ import express from 'express' import multer from 'multer' import finetuningController from '../../controllers/finetuning' -const router = express.Router() +const router: express.Router = express.Router() // Use memory storage for multer to store files in buffer const upload = multer({ storage: multer.memoryStorage() }) diff --git a/studio-frontend/packages/server/src/routes/flow-config/index.ts b/studio-frontend/packages/server/src/routes/flow-config/index.ts index bd84150..1d2cb69 100644 --- a/studio-frontend/packages/server/src/routes/flow-config/index.ts +++ b/studio-frontend/packages/server/src/routes/flow-config/index.ts @@ -1,6 +1,6 @@ import express from 'express' import flowConfigsController from '../../controllers/flow-configs' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/get-upload-file/index.ts b/studio-frontend/packages/server/src/routes/get-upload-file/index.ts index 319c73e..714af31 100644 --- a/studio-frontend/packages/server/src/routes/get-upload-file/index.ts +++ b/studio-frontend/packages/server/src/routes/get-upload-file/index.ts @@ -1,6 +1,6 @@ import express from 'express' import getUploadFileController from '../../controllers/get-upload-file' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/', getUploadFileController.streamUploadedFile) diff --git a/studio-frontend/packages/server/src/routes/get-upload-path/index.ts b/studio-frontend/packages/server/src/routes/get-upload-path/index.ts index 48827c9..ddfe3f0 100644 --- a/studio-frontend/packages/server/src/routes/get-upload-path/index.ts +++ b/studio-frontend/packages/server/src/routes/get-upload-path/index.ts @@ -1,6 +1,6 @@ import express from 'express' import getUploadPathController from '../../controllers/get-upload-path' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/', getUploadPathController.getPathForUploads) diff --git a/studio-frontend/packages/server/src/routes/index.ts b/studio-frontend/packages/server/src/routes/index.ts index e1a92a5..bd57a14 100644 --- a/studio-frontend/packages/server/src/routes/index.ts +++ b/studio-frontend/packages/server/src/routes/index.ts @@ -43,7 +43,7 @@ import vectorRouter from './vectors' import verifyRouter from './verify' import versionRouter from './versions' -const router = express.Router() +const router: express.Router = express.Router() router.use('/ping', pingRouter) router.use('/apikey', apikeyRouter) diff --git a/studio-frontend/packages/server/src/routes/internal-chat-messages/index.ts b/studio-frontend/packages/server/src/routes/internal-chat-messages/index.ts index 5dcf1e6..b5814c8 100644 --- a/studio-frontend/packages/server/src/routes/internal-chat-messages/index.ts +++ b/studio-frontend/packages/server/src/routes/internal-chat-messages/index.ts @@ -1,6 +1,6 @@ import express from 'express' import chatMessagesController from '../../controllers/chat-messages' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/internal-predictions/index.ts b/studio-frontend/packages/server/src/routes/internal-predictions/index.ts index 8e39dce..80fb8e1 100644 --- a/studio-frontend/packages/server/src/routes/internal-predictions/index.ts +++ b/studio-frontend/packages/server/src/routes/internal-predictions/index.ts @@ -1,6 +1,6 @@ import express from 'express' import internalPredictionsController from '../../controllers/internal-predictions' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post(['/', '/:id'], internalPredictionsController.createInternalPrediction) diff --git a/studio-frontend/packages/server/src/routes/leads/index.ts b/studio-frontend/packages/server/src/routes/leads/index.ts index 64209ee..8acdfc4 100644 --- a/studio-frontend/packages/server/src/routes/leads/index.ts +++ b/studio-frontend/packages/server/src/routes/leads/index.ts @@ -1,6 +1,6 @@ import express from 'express' import leadsController from '../../controllers/leads' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', leadsController.createLeadInChatflow) diff --git a/studio-frontend/packages/server/src/routes/load-prompts/index.ts b/studio-frontend/packages/server/src/routes/load-prompts/index.ts index a12afba..b11e7c1 100644 --- a/studio-frontend/packages/server/src/routes/load-prompts/index.ts +++ b/studio-frontend/packages/server/src/routes/load-prompts/index.ts @@ -1,6 +1,6 @@ import express from 'express' import loadPromptsController from '../../controllers/load-prompts' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', loadPromptsController.createPrompt) diff --git a/studio-frontend/packages/server/src/routes/marketplaces/index.ts b/studio-frontend/packages/server/src/routes/marketplaces/index.ts index d97f96f..3242789 100644 --- a/studio-frontend/packages/server/src/routes/marketplaces/index.ts +++ b/studio-frontend/packages/server/src/routes/marketplaces/index.ts @@ -1,6 +1,6 @@ import express from 'express' import marketplacesController from '../../controllers/marketplaces' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/templates', marketplacesController.getAllTemplates) diff --git a/studio-frontend/packages/server/src/routes/node-configs/index.ts b/studio-frontend/packages/server/src/routes/node-configs/index.ts index 4c44513..232c15b 100644 --- a/studio-frontend/packages/server/src/routes/node-configs/index.ts +++ b/studio-frontend/packages/server/src/routes/node-configs/index.ts @@ -1,6 +1,6 @@ import express from 'express' import nodeConfigsController from '../../controllers/node-configs' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', nodeConfigsController.getAllNodeConfigs) diff --git a/studio-frontend/packages/server/src/routes/node-custom-functions/index.ts b/studio-frontend/packages/server/src/routes/node-custom-functions/index.ts index 9fa33d4..8a66627 100644 --- a/studio-frontend/packages/server/src/routes/node-custom-functions/index.ts +++ b/studio-frontend/packages/server/src/routes/node-custom-functions/index.ts @@ -1,6 +1,6 @@ import express from 'express' import nodesRouter from '../../controllers/nodes' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/node-icons/index.ts b/studio-frontend/packages/server/src/routes/node-icons/index.ts index 3dc51b9..27d755a 100644 --- a/studio-frontend/packages/server/src/routes/node-icons/index.ts +++ b/studio-frontend/packages/server/src/routes/node-icons/index.ts @@ -1,6 +1,6 @@ import express from 'express' import nodesController from '../../controllers/nodes' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/node-load-methods/index.ts b/studio-frontend/packages/server/src/routes/node-load-methods/index.ts index 317fd81..954252c 100644 --- a/studio-frontend/packages/server/src/routes/node-load-methods/index.ts +++ b/studio-frontend/packages/server/src/routes/node-load-methods/index.ts @@ -1,6 +1,6 @@ import express from 'express' import nodesRouter from '../../controllers/nodes' -const router = express.Router() +const router: express.Router = express.Router() router.post(['/', '/:name'], nodesRouter.getSingleNodeAsyncOptions) diff --git a/studio-frontend/packages/server/src/routes/nodes/index.ts b/studio-frontend/packages/server/src/routes/nodes/index.ts index 1c9e59c..e8a2b76 100644 --- a/studio-frontend/packages/server/src/routes/nodes/index.ts +++ b/studio-frontend/packages/server/src/routes/nodes/index.ts @@ -1,6 +1,6 @@ import express from 'express' import nodesController from '../../controllers/nodes' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/', nodesController.getAllNodes) diff --git a/studio-frontend/packages/server/src/routes/openai-assistants-files/index.ts b/studio-frontend/packages/server/src/routes/openai-assistants-files/index.ts index 771e5fd..419fcef 100644 --- a/studio-frontend/packages/server/src/routes/openai-assistants-files/index.ts +++ b/studio-frontend/packages/server/src/routes/openai-assistants-files/index.ts @@ -3,7 +3,7 @@ import multer from 'multer' import path from 'path' import openaiAssistantsController from '../../controllers/openai-assistants' -const router = express.Router() +const router: express.Router = express.Router() const upload = multer({ dest: `${path.join(__dirname, '..', '..', '..', 'uploads')}/` }) router.post('/download/', openaiAssistantsController.getFileFromAssistant) diff --git a/studio-frontend/packages/server/src/routes/openai-assistants-vector-store/index.ts b/studio-frontend/packages/server/src/routes/openai-assistants-vector-store/index.ts index 1560e37..efd0f8e 100644 --- a/studio-frontend/packages/server/src/routes/openai-assistants-vector-store/index.ts +++ b/studio-frontend/packages/server/src/routes/openai-assistants-vector-store/index.ts @@ -3,7 +3,7 @@ import multer from 'multer' import path from 'path' import openaiAssistantsVectorStoreController from '../../controllers/openai-assistants-vector-store' -const router = express.Router() +const router: express.Router = express.Router() const upload = multer({ dest: `${path.join(__dirname, '..', '..', '..', 'uploads')}/` }) // CREATE diff --git a/studio-frontend/packages/server/src/routes/openai-assistants/index.ts b/studio-frontend/packages/server/src/routes/openai-assistants/index.ts index 1c82a92..cfac2ea 100644 --- a/studio-frontend/packages/server/src/routes/openai-assistants/index.ts +++ b/studio-frontend/packages/server/src/routes/openai-assistants/index.ts @@ -1,6 +1,6 @@ import express from 'express' import openaiAssistantsController from '../../controllers/openai-assistants' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/ping/index.ts b/studio-frontend/packages/server/src/routes/ping/index.ts index b026710..205f0b7 100644 --- a/studio-frontend/packages/server/src/routes/ping/index.ts +++ b/studio-frontend/packages/server/src/routes/ping/index.ts @@ -1,6 +1,6 @@ import express from 'express' import pingController from '../../controllers/ping' -const router = express.Router() +const router: express.Router = express.Router() // GET router.get('/', pingController.getPing) diff --git a/studio-frontend/packages/server/src/routes/predictions/index.ts b/studio-frontend/packages/server/src/routes/predictions/index.ts index ded2d34..a8cf640 100644 --- a/studio-frontend/packages/server/src/routes/predictions/index.ts +++ b/studio-frontend/packages/server/src/routes/predictions/index.ts @@ -3,7 +3,7 @@ import multer from 'multer' import path from 'path' import predictionsController from '../../controllers/predictions' -const router = express.Router() +const router: express.Router = express.Router() const upload = multer({ dest: `${path.join(__dirname, '..', '..', '..', 'uploads')}/` }) diff --git a/studio-frontend/packages/server/src/routes/prompts-lists/index.ts b/studio-frontend/packages/server/src/routes/prompts-lists/index.ts index 9b92c36..51800a4 100644 --- a/studio-frontend/packages/server/src/routes/prompts-lists/index.ts +++ b/studio-frontend/packages/server/src/routes/prompts-lists/index.ts @@ -1,6 +1,6 @@ import express from 'express' import promptsListController from '../../controllers/prompts-lists' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', promptsListController.createPromptsList) diff --git a/studio-frontend/packages/server/src/routes/public-chatbots/index.ts b/studio-frontend/packages/server/src/routes/public-chatbots/index.ts index 18ee9e4..7750fe7 100644 --- a/studio-frontend/packages/server/src/routes/public-chatbots/index.ts +++ b/studio-frontend/packages/server/src/routes/public-chatbots/index.ts @@ -1,6 +1,6 @@ import express from 'express' import chatflowsController from '../../controllers/chatflows' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/public-chatflows/index.ts b/studio-frontend/packages/server/src/routes/public-chatflows/index.ts index 640fe3a..31c930f 100644 --- a/studio-frontend/packages/server/src/routes/public-chatflows/index.ts +++ b/studio-frontend/packages/server/src/routes/public-chatflows/index.ts @@ -1,6 +1,6 @@ import express from 'express' import chatflowsController from '../../controllers/chatflows' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/stats/index.ts b/studio-frontend/packages/server/src/routes/stats/index.ts index 8ca64d3..7708b3a 100644 --- a/studio-frontend/packages/server/src/routes/stats/index.ts +++ b/studio-frontend/packages/server/src/routes/stats/index.ts @@ -1,7 +1,7 @@ import express from 'express' import statsController from '../../controllers/stats' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get(['/', '/:id'], statsController.getChatflowStats) diff --git a/studio-frontend/packages/server/src/routes/tools/index.ts b/studio-frontend/packages/server/src/routes/tools/index.ts index e97fb5c..b29e823 100644 --- a/studio-frontend/packages/server/src/routes/tools/index.ts +++ b/studio-frontend/packages/server/src/routes/tools/index.ts @@ -1,7 +1,7 @@ import express from 'express' import toolsController from '../../controllers/tools' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', toolsController.createTool) diff --git a/studio-frontend/packages/server/src/routes/upsert-history/index.ts b/studio-frontend/packages/server/src/routes/upsert-history/index.ts index 3e3c9c1..cdf5512 100644 --- a/studio-frontend/packages/server/src/routes/upsert-history/index.ts +++ b/studio-frontend/packages/server/src/routes/upsert-history/index.ts @@ -1,6 +1,6 @@ import express from 'express' import upsertHistoryController from '../../controllers/upsert-history' -const router = express.Router() +const router: express.Router = express.Router() // CREATE diff --git a/studio-frontend/packages/server/src/routes/variables/index.ts b/studio-frontend/packages/server/src/routes/variables/index.ts index f6d3625..945f99f 100644 --- a/studio-frontend/packages/server/src/routes/variables/index.ts +++ b/studio-frontend/packages/server/src/routes/variables/index.ts @@ -1,7 +1,7 @@ import express from 'express' import variablesController from '../../controllers/variables' -const router = express.Router() +const router: express.Router = express.Router() // CREATE router.post('/', variablesController.createVariable) diff --git a/studio-frontend/packages/server/src/routes/vectors/index.ts b/studio-frontend/packages/server/src/routes/vectors/index.ts index cc257bc..843ea63 100644 --- a/studio-frontend/packages/server/src/routes/vectors/index.ts +++ b/studio-frontend/packages/server/src/routes/vectors/index.ts @@ -3,7 +3,7 @@ import multer from 'multer' import path from 'path' import vectorsController from '../../controllers/vectors' -const router = express.Router() +const router: express.Router = express.Router() const upload = multer({ dest: `${path.join(__dirname, '..', '..', '..', 'uploads')}/` }) diff --git a/studio-frontend/packages/server/src/routes/verify/index.ts b/studio-frontend/packages/server/src/routes/verify/index.ts index b00a59a..15d358c 100644 --- a/studio-frontend/packages/server/src/routes/verify/index.ts +++ b/studio-frontend/packages/server/src/routes/verify/index.ts @@ -1,6 +1,6 @@ import express from 'express' import apikeyController from '../../controllers/apikey' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get(['/apikey/', '/apikey/:apikey'], apikeyController.verifyApiKey) diff --git a/studio-frontend/packages/server/src/routes/versions/index.ts b/studio-frontend/packages/server/src/routes/versions/index.ts index 8aa60a2..1dd434d 100644 --- a/studio-frontend/packages/server/src/routes/versions/index.ts +++ b/studio-frontend/packages/server/src/routes/versions/index.ts @@ -1,6 +1,6 @@ import express from 'express' import versionsController from '../../controllers/versions' -const router = express.Router() +const router: express.Router = express.Router() // READ router.get('/', versionsController.getVersion) diff --git a/studio-frontend/packages/server/src/services/openai-assistants-vector-store/index.ts b/studio-frontend/packages/server/src/services/openai-assistants-vector-store/index.ts index 46f9c18..7b598e1 100644 --- a/studio-frontend/packages/server/src/services/openai-assistants-vector-store/index.ts +++ b/studio-frontend/packages/server/src/services/openai-assistants-vector-store/index.ts @@ -24,7 +24,7 @@ const getAssistantVectorStore = async (credentialId: string, vectorStoreId: stri } const openai = new OpenAI({ apiKey: openAIApiKey }) - const dbResponse = await openai.beta.vectorStores.retrieve(vectorStoreId) + const dbResponse = await openai.vectorStores.retrieve(vectorStoreId) return dbResponse } catch (error) { throw new InternalFlowiseError( @@ -51,7 +51,7 @@ const listAssistantVectorStore = async (credentialId: string) => { } const openai = new OpenAI({ apiKey: openAIApiKey }) - const dbResponse = await openai.beta.vectorStores.list() + const dbResponse = await openai.vectorStores.list() return dbResponse.data } catch (error) { throw new InternalFlowiseError( @@ -61,7 +61,7 @@ const listAssistantVectorStore = async (credentialId: string) => { } } -const createAssistantVectorStore = async (credentialId: string, obj: OpenAI.Beta.VectorStores.VectorStoreCreateParams) => { +const createAssistantVectorStore = async (credentialId: string, obj: OpenAI.VectorStores.VectorStoreCreateParams) => { try { const appServer = getRunningExpressApp() const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({ @@ -78,7 +78,7 @@ const createAssistantVectorStore = async (credentialId: string, obj: OpenAI.Beta } const openai = new OpenAI({ apiKey: openAIApiKey }) - const dbResponse = await openai.beta.vectorStores.create(obj) + const dbResponse = await openai.vectorStores.create(obj) return dbResponse } catch (error) { throw new InternalFlowiseError( @@ -91,7 +91,7 @@ const createAssistantVectorStore = async (credentialId: string, obj: OpenAI.Beta const updateAssistantVectorStore = async ( credentialId: string, vectorStoreId: string, - obj: OpenAI.Beta.VectorStores.VectorStoreUpdateParams + obj: OpenAI.VectorStores.VectorStoreUpdateParams ) => { try { const appServer = getRunningExpressApp() @@ -109,8 +109,8 @@ const updateAssistantVectorStore = async ( } const openai = new OpenAI({ apiKey: openAIApiKey }) - const dbResponse = await openai.beta.vectorStores.update(vectorStoreId, obj) - const vectorStoreFiles = await openai.beta.vectorStores.files.list(vectorStoreId) + const dbResponse = await openai.vectorStores.update(vectorStoreId, obj) + const vectorStoreFiles = await openai.vectorStores.files.list(vectorStoreId) if (vectorStoreFiles.data?.length) { const files = [] for (const file of vectorStoreFiles.data) { @@ -145,7 +145,7 @@ const deleteAssistantVectorStore = async (credentialId: string, vectorStoreId: s } const openai = new OpenAI({ apiKey: openAIApiKey }) - const dbResponse = await openai.beta.vectorStores.del(vectorStoreId) + const dbResponse = await openai.vectorStores.del(vectorStoreId) return dbResponse } catch (error) { throw new InternalFlowiseError( @@ -189,7 +189,7 @@ const uploadFilesToAssistantVectorStore = async ( const file_ids = [...uploadedFiles.map((file) => file.id)] - const res = await openai.beta.vectorStores.fileBatches.createAndPoll(vectorStoreId, { + const res = await openai.vectorStores.fileBatches.createAndPoll(vectorStoreId, { file_ids }) if (res.status === 'completed' && res.file_counts.completed === uploadedFiles.length) return uploadedFiles @@ -231,7 +231,7 @@ const deleteFilesFromAssistantVectorStore = async (credentialId: string, vectorS const deletedFileIds = [] let count = 0 for (const file of file_ids) { - const res = await openai.beta.vectorStores.files.del(vectorStoreId, file) + const res = await openai.vectorStores.files.del(vectorStoreId, file) if (res.deleted) { deletedFileIds.push(file) count += 1 diff --git a/studio-frontend/packages/server/src/services/openai-assistants/index.ts b/studio-frontend/packages/server/src/services/openai-assistants/index.ts index c908a54..7c3df3e 100644 --- a/studio-frontend/packages/server/src/services/openai-assistants/index.ts +++ b/studio-frontend/packages/server/src/services/openai-assistants/index.ts @@ -68,10 +68,10 @@ const getSingleOpenaiAssistant = async (credentialId: string, assistantId: strin if (dbResponse.tool_resources?.file_search?.vector_store_ids?.length) { // Since there can only be 1 vector store per assistant const vectorStoreId = dbResponse.tool_resources.file_search.vector_store_ids[0] - const vectorStoreFiles = await openai.beta.vectorStores.files.list(vectorStoreId) + const vectorStoreFiles = await openai.vectorStores.files.list(vectorStoreId) const fileIds = vectorStoreFiles.data?.map((file) => file.id) ?? [] ;(dbResponse.tool_resources.file_search as any).files = [...existingFiles.filter((file) => fileIds.includes(file.id))] - ;(dbResponse.tool_resources.file_search as any).vector_store_object = await openai.beta.vectorStores.retrieve(vectorStoreId) + ;(dbResponse.tool_resources.file_search as any).vector_store_object = await openai.vectorStores.retrieve(vectorStoreId) } return dbResponse } catch (error) { diff --git a/studio-frontend/packages/server/src/utils/SSEStreamer.ts b/studio-frontend/packages/server/src/utils/SSEStreamer.ts index 4217925..ef7139d 100644 --- a/studio-frontend/packages/server/src/utils/SSEStreamer.ts +++ b/studio-frontend/packages/server/src/utils/SSEStreamer.ts @@ -211,8 +211,115 @@ export class SSEStreamer implements IServerSideEventStreamer { if (apiResponse.memoryType) { metadataJson['memoryType'] = apiResponse.memoryType } + if (apiResponse.followUpPrompts) { + metadataJson['followUpPrompts'] = + typeof apiResponse.followUpPrompts === 'string' ? JSON.parse(apiResponse.followUpPrompts) : apiResponse.followUpPrompts + } + if (apiResponse.flowVariables) { + metadataJson['flowVariables'] = + typeof apiResponse.flowVariables === 'string' ? JSON.parse(apiResponse.flowVariables) : apiResponse.flowVariables + } if (Object.keys(metadataJson).length > 0) { this.streamCustomEvent(chatId, 'metadata', metadataJson) } } + + streamCalledToolsEvent(chatId: string, data: any): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'calledTools', + data: data + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamAgentFlowExecutedDataEvent(chatId: string, data: any): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'agentFlowExecutedData', + data: data + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamAgentFlowEvent(chatId: string, data: any): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'agentFlowEvent', + data: data + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamNextAgentFlowEvent(chatId: string, data: any): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'nextAgentFlow', + data: data + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamUsageMetadataEvent(chatId: string, data: any): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'usageMetadata', + data: data + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamTTSStartEvent(chatId: string, chatMessageId: string, format: string): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'tts_start', + data: { chatMessageId, format } + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamTTSDataEvent(chatId: string, chatMessageId: string, audioChunk: string): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'tts_data', + data: { chatMessageId, audioChunk } + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamTTSEndEvent(chatId: string, chatMessageId: string): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'tts_end', + data: { chatMessageId } + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } + + streamTTSAbortEvent(chatId: string, chatMessageId: string): void { + const client = this.clients[chatId] + if (client) { + const clientResponse = { + event: 'tts_abort', + data: { chatMessageId } + } + client.response.write('message:\ndata:' + JSON.stringify(clientResponse) + '\n\n') + } + } } diff --git a/studio-frontend/packages/server/src/utils/buildAgentGraph.ts b/studio-frontend/packages/server/src/utils/buildAgentGraph.ts index ea44382..fd9a198 100644 --- a/studio-frontend/packages/server/src/utils/buildAgentGraph.ts +++ b/studio-frontend/packages/server/src/utils/buildAgentGraph.ts @@ -361,7 +361,7 @@ export const buildAgentGraph = async ( const connectedToolNode = reactFlowNodes.find((node) => node.id === tooNodeId) // Map raw tool calls to used tools, to be shown on interrupted message - const mappedToolCalls = lastMessageRaw.tool_calls.map((toolCall) => { + const mappedToolCalls = lastMessageRaw.tool_calls.map((toolCall: any) => { return { tool: toolCall.name, toolInput: toolCall.args, toolOutput: '' } }) diff --git a/studio-frontend/packages/server/src/utils/fileRepository.ts b/studio-frontend/packages/server/src/utils/fileRepository.ts index 1147aeb..245b9ec 100644 --- a/studio-frontend/packages/server/src/utils/fileRepository.ts +++ b/studio-frontend/packages/server/src/utils/fileRepository.ts @@ -46,7 +46,7 @@ export const containsBase64File = (chatflow: ChatFlow) => { return found } -export const updateFlowDataWithFilePaths = async (chatflowid: string, flowData: string) => { +export const updateFlowDataWithFilePaths = async (chatflowid: string, flowData: string, orgId: string = '') => { try { const parsedFlowData: IReactFlowObject = JSON.parse(flowData) const re = new RegExp('^data.*;base64', 'i') @@ -75,14 +75,16 @@ export const updateFlowDataWithFilePaths = async (chatflowid: string, flowData: for (let j = 0; j < files.length; j++) { const file = files[j] if (re.test(file)) { - node.data.inputs[key] = await addBase64FilesToStorage(file, chatflowid, fileNames) + const result = await addBase64FilesToStorage(file, chatflowid, fileNames, orgId) + node.data.inputs[key] = result.path } } } catch (e) { continue } } else if (re.test(input)) { - node.data.inputs[key] = await addBase64FilesToStorage(input, chatflowid, fileNames) + const result = await addBase64FilesToStorage(input, chatflowid, fileNames, orgId) + node.data.inputs[key] = result.path } } } diff --git a/studio-frontend/packages/ui/package.json b/studio-frontend/packages/ui/package.json index 94840aa..6051cb7 100644 --- a/studio-frontend/packages/ui/package.json +++ b/studio-frontend/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "flowise-ui", - "version": "2.1.4", + "version": "3.0.10", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://flowiseai.com", "author": { @@ -13,23 +13,29 @@ "@opentelemetry/sdk-trace-node":"latest", "@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-json": "^6.0.1", - "@codemirror/view": "^6.22.3", + "@codemirror/lang-markdown": "^6.2.5", + "@codemirror/view": "^6.26.3", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", + "@lezer/highlight": "^1.2.1", "@microsoft/fetch-event-source": "^2.0.1", "@mui/base": "5.0.0-beta.40", "@mui/icons-material": "^5.15.0", "@mui/lab": "5.0.0-alpha.156", "@mui/material": "5.15.0", + "@mui/system": "^5.15.0", "@mui/x-data-grid": "6.8.0", + "@mui/x-tree-view": "^6.17.0", "@react-keycloak/web": "^3.4.0", - "@tabler/icons-react": "3.7.0", + "@reduxjs/toolkit": "^2.2.7", + "@tabler/icons-react": "^3.30.0", "@uiw/codemirror-theme-sublime": "^4.21.21", "@uiw/codemirror-theme-vscode": "^4.21.21", "@uiw/react-codemirror": "^4.21.21", - "axios": "1.8.2", + "axios": "1.12.0", "clsx": "^1.1.1", + "dompurify": "^3.2.6", "dotenv": "^16.0.0", "flowise-embed": "latest", "flowise-embed-react": "latest", @@ -40,11 +46,12 @@ "html-react-parser": "^3.0.4", "keycloak-js": "^26.0.5", "lodash": "^4.17.21", + "lowlight": "^3.3.0", "moment": "^2.29.3", "notistack": "^2.0.4", "prop-types": "^15.7.2", "react": "^18.2.0", - "react-code-blocks": "^0.0.9-0", + "react-code-blocks": "^0.1.6", "react-color": "^2.19.3", "react-datepicker": "^4.21.0", "react-device-detect": "^1.17.0", @@ -52,21 +59,24 @@ "react-markdown": "^8.0.6", "react-perfect-scrollbar": "^1.5.8", "react-redux": "^8.0.5", + "react-rewards": "^2.1.0", "react-router": "~6.3.0", "react-router-dom": "~6.3.0", "react-syntax-highlighter": "^15.5.0", "reactflow": "^11.5.6", + "recharts": "^2.12.6", "redux": "^4.0.5", "rehype-mathjax": "^4.0.2", - "rehype-raw": "^7.0.0", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", + "showdown": "^2.1.0", "socket.io-client": "^4.6.1", + "tippy.js": "^6.3.7", "uuid": "^9.0.1", "yup": "^0.32.9" }, "scripts": { - "dev": "vite --host", + "dev": "vite", "start": "vite", "build": "vite build", "clean": "rimraf build", diff --git a/studio-frontend/packages/ui/src/ui-component/dialog/InputHintDialog.jsx b/studio-frontend/packages/ui/src/ui-component/dialog/InputHintDialog.jsx index 0eb8651..d1ec20e 100644 --- a/studio-frontend/packages/ui/src/ui-component/dialog/InputHintDialog.jsx +++ b/studio-frontend/packages/ui/src/ui-component/dialog/InputHintDialog.jsx @@ -1,11 +1,6 @@ import { createPortal } from 'react-dom' import PropTypes from 'prop-types' -import rehypeMathjax from 'rehype-mathjax' -import rehypeRaw from 'rehype-raw' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown' -import { CodeBlock } from '@/ui-component/markdown/CodeBlock' import { Dialog, DialogContent, DialogTitle } from '@mui/material' const InputHintDialog = ({ show, dialogProps, onCancel }) => { @@ -24,27 +19,7 @@ const InputHintDialog = ({ show, dialogProps, onCancel }) => { {dialogProps.label} - - ) : ( - - {children} - - ) - } - }} - > + {dialogProps?.value} diff --git a/studio-frontend/packages/ui/src/ui-component/dialog/PromptLangsmithHubDialog.jsx b/studio-frontend/packages/ui/src/ui-component/dialog/PromptLangsmithHubDialog.jsx index acc246f..00ee677 100644 --- a/studio-frontend/packages/ui/src/ui-component/dialog/PromptLangsmithHubDialog.jsx +++ b/studio-frontend/packages/ui/src/ui-component/dialog/PromptLangsmithHubDialog.jsx @@ -3,11 +3,6 @@ import { useState, useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import PropTypes from 'prop-types' -import rehypeMathjax from 'rehype-mathjax' -import rehypeRaw from 'rehype-raw' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' - // MUI import { Box, @@ -536,28 +531,7 @@ const PromptLangsmithHubDialog = ({ promptType, show, onCancel, onSubmit }) => { } }} > - - ) : ( - - {children} - - ) - } - }} - > + {selectedPrompt?.readme} diff --git a/studio-frontend/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/studio-frontend/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx index 5b40d1e..ed8fc2d 100644 --- a/studio-frontend/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx +++ b/studio-frontend/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx @@ -3,10 +3,6 @@ import { useDispatch, useSelector } from 'react-redux' import { useState, useEffect, forwardRef } from 'react' import PropTypes from 'prop-types' import moment from 'moment' -import rehypeMathjax from 'rehype-mathjax' -import rehypeRaw from 'rehype-raw' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' import axios from 'axios' import { cloneDeep } from 'lodash' @@ -642,27 +638,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { } else { return ( - ) : ( - - {children} - - ) - } - }} + chatflowid={dialogProps.chatflow.id} + isDialog={true} > {item.data} @@ -1113,44 +1090,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { )} {agent.messages.length > 0 && ( - ) : ( - - {children} - - ) - } - }} + chatflowid={dialogProps.chatflow.id} + isDialog={true} > {agent.messages.length > 1 ? agent.messages.join('\\n') @@ -1264,27 +1205,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
{/* Messages are being rendered in Markdown format */} - ) : ( - - {children} - - ) - } - }} + chatflowid={dialogProps.chatflow.id} + isDialog={true} > {message.message} diff --git a/studio-frontend/packages/ui/src/ui-component/markdown/MemoizedReactMarkdown.jsx b/studio-frontend/packages/ui/src/ui-component/markdown/MemoizedReactMarkdown.jsx index 523585f..680dd22 100644 --- a/studio-frontend/packages/ui/src/ui-component/markdown/MemoizedReactMarkdown.jsx +++ b/studio-frontend/packages/ui/src/ui-component/markdown/MemoizedReactMarkdown.jsx @@ -1,19 +1,168 @@ -import { memo } from 'react' +import { memo, useMemo } from 'react' import PropTypes from 'prop-types' import ReactMarkdown from 'react-markdown' import './Markdown.css' +import { CodeBlock } from '../markdown/CodeBlock' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import rehypeMathjax from 'rehype-mathjax' +/** + * Checks if text likely contains LaTeX math notation + * @param {string} text - Text to check for LaTeX math + * @param {Object[]} customPatterns - Additional regex patterns to check + * @returns {boolean} - Whether LaTeX math is likely present + */ +const containsLaTeX = (text, customPatterns = []) => { + if (!text || typeof text !== 'string') return false + + // Common LaTeX patterns - more permissive to catch edge cases + const defaultPatterns = [ + { regex: /\$\$.+?\$\$/s, name: 'Block math: $$...$$' }, + { regex: /\\\(.+?\\\)/s, name: 'Inline math: \\(...\\)' }, + { regex: /\\\[[\s\S]*?\\\]/, name: 'Display math: \\[...\\]' }, + { + regex: /\\begin{(equation|align|gather|math|matrix|bmatrix|pmatrix|vmatrix|cases)}.+?\\end{\1}/s, + name: 'Environment math' + }, + { regex: /\$(.*?[\\{}_^].*?)\$/, name: 'Inline math with $' }, + { regex: /\\frac/, name: 'LaTeX command: \\frac' }, + { regex: /\\sqrt/, name: 'LaTeX command: \\sqrt' }, + { regex: /\\pm/, name: 'LaTeX command: \\pm' }, + { regex: /\\cdot/, name: 'LaTeX command: \\cdot' }, + { regex: /\\text/, name: 'LaTeX command: \\text' }, + { regex: /\\sum/, name: 'LaTeX command: \\sum' }, + { regex: /\\prod/, name: 'LaTeX command: \\prod' }, + { regex: /\\int/, name: 'LaTeX command: \\int' } + ] + + // Combine default and custom patterns + const patterns = [...defaultPatterns, ...customPatterns] + + for (const pattern of patterns) { + if (pattern.regex.test(text)) { + return true + } + } + + return false +} + +/** + * Preprocesses text to make LaTeX syntax more compatible with Markdown + * @param {string} text - Original text with potentially problematic LaTeX syntax + * @returns {string} - Text with LaTeX syntax adjusted for better compatibility + */ +const preprocessLatex = (text) => { + if (!text || typeof text !== 'string') return text + + // Replace problematic LaTeX patterns with more compatible alternatives + const processedText = text + // Convert display math with indentation to dollar-dollar format + .replace(/(\n\s*)\\\[([\s\S]*?)\\\](\s*\n|$)/g, (match, before, content, after) => { + // Preserve indentation but use $$ format which is more reliably handled + return `${before}$$${content}$$${after}` + }) + // Convert inline math to dollar format with spaces to avoid conflicts + .replace(/\\\(([\s\S]*?)\\\)/g, '$ $1 $') + + return processedText +} + +/** + * Enhanced Markdown component with memoization for better performance + * Supports various plugins and custom rendering components + */ export const MemoizedReactMarkdown = memo( - ({ children, ...props }) => ( -
- {children} -
- ), - (prevProps, nextProps) => prevProps.children === nextProps.children + ({ children, ...props }) => { + // Preprocess text to improve LaTeX compatibility + const processedChildren = useMemo( + () => (typeof children === 'string' ? preprocessLatex(children) : children), + [children] + ) + + // Enable math by default unless explicitly disabled + const shouldEnableMath = useMemo(() => { + const hasLatex = processedChildren && containsLaTeX(processedChildren, props.mathPatterns || []) + return props.disableMath === true ? false : props.forceMath || hasLatex + }, [processedChildren, props.forceMath, props.disableMath, props.mathPatterns]) + + // Configure plugins based on content + const remarkPlugins = useMemo(() => { + if (props.remarkPlugins) return props.remarkPlugins + return shouldEnableMath ? [remarkGfm, remarkMath] : [remarkGfm] + }, [props.remarkPlugins, shouldEnableMath]) + + const rehypePlugins = useMemo(() => { + if (props.rehypePlugins) return props.rehypePlugins + return shouldEnableMath ? [rehypeMathjax] : [] + }, [props.rehypePlugins, shouldEnableMath]) + + return ( +
+ + ) : ( + + {children} + + ) + }, + p({ children }) { + return

{children}

+ }, + ...props.components + }} + {...props} + > + {processedChildren} +
+
+ ) + }, + (prevProps, nextProps) => { + // More detailed comparison for better memoization + if (prevProps.children !== nextProps.children) return false + + // Check if other props have changed + const prevEntries = Object.entries(prevProps).filter(([key]) => key !== 'children') + const nextEntries = Object.entries(nextProps).filter(([key]) => key !== 'children') + + if (prevEntries.length !== nextEntries.length) return false + + // Simple shallow comparison of remaining props + for (const [key, value] of prevEntries) { + if (key === 'components' || key === 'remarkPlugins' || key === 'rehypePlugins') continue // Skip complex objects + if (nextProps[key] !== value) return false + } + + return true + } ) MemoizedReactMarkdown.displayName = 'MemoizedReactMarkdown' MemoizedReactMarkdown.propTypes = { - children: PropTypes.any + children: PropTypes.any, + chatflowid: PropTypes.string, + isFullWidth: PropTypes.bool, + remarkPlugins: PropTypes.array, + rehypePlugins: PropTypes.array, + components: PropTypes.object, + forceMath: PropTypes.bool, + disableMath: PropTypes.bool, + mathPatterns: PropTypes.array } diff --git a/studio-frontend/packages/ui/src/views/chatmessage/ChatMessage.jsx b/studio-frontend/packages/ui/src/views/chatmessage/ChatMessage.jsx index ebabf12..91afdae 100644 --- a/studio-frontend/packages/ui/src/views/chatmessage/ChatMessage.jsx +++ b/studio-frontend/packages/ui/src/views/chatmessage/ChatMessage.jsx @@ -2,10 +2,6 @@ import { useState, useRef, useEffect, useCallback, Fragment } from 'react' import { useSelector, useDispatch } from 'react-redux' import PropTypes from 'prop-types' import { cloneDeep } from 'lodash' -import rehypeMathjax from 'rehype-mathjax' -import rehypeRaw from 'rehype-raw' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' import axios from 'axios' import { v4 as uuidv4 } from 'uuid' import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source' @@ -1342,27 +1338,8 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview } else { return ( - ) : ( - - {children} - - ) - } - }} + chatflowid={chatflowid} + isDialog={isDialog} > {item.data} @@ -1588,27 +1565,8 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview )} {agent.messages.length > 0 && ( - ) : ( - - {children} - - ) - } - }} + chatflowid={chatflowid} + isDialog={isDialog} > {agent.messages.length > 1 ? agent.messages.join('\\n') @@ -1774,27 +1732,8 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview <> {/* Messages are being rendered in Markdown format */} - ) : ( - - {children} - - ) - } - }} + chatflowid={chatflowid} + isDialog={isDialog} > {message.message} diff --git a/studio-frontend/turbo.json b/studio-frontend/turbo.json index bea91f3..010a193 100644 --- a/studio-frontend/turbo.json +++ b/studio-frontend/turbo.json @@ -3,11 +3,12 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**", "build/**"] }, "test": {}, "dev": { - "cache": false + "cache": false, + "persistent": true } } } From dfa5ebcab743146c96dddab1e4dfc01375d2fcf9 Mon Sep 17 00:00:00 2001 From: wwanarif Date: Sat, 13 Dec 2025 14:27:32 +0000 Subject: [PATCH 9/9] add compat shim for urllib3 2.x Signed-off-by: wwanarif --- studio-backend/app/__init__.py | 4 ++++ studio-backend/app/compat.py | 22 ++++++++++++++++++++++ studio-backend/app/main.py | 6 ++++++ 3 files changed, 32 insertions(+) create mode 100644 studio-backend/app/compat.py diff --git a/studio-backend/app/__init__.py b/studio-backend/app/__init__.py index e69de29..68623b9 100644 --- a/studio-backend/app/__init__.py +++ b/studio-backend/app/__init__.py @@ -0,0 +1,4 @@ +from app.compat import ensure_urllib3_getheaders + +# Ensure urllib3 2.x exposes HTTPResponse.getheaders for kubernetes client compatibility. +ensure_urllib3_getheaders() diff --git a/studio-backend/app/compat.py b/studio-backend/app/compat.py new file mode 100644 index 0000000..ecbb7b3 --- /dev/null +++ b/studio-backend/app/compat.py @@ -0,0 +1,22 @@ +""" +Compatibility shims for third-party libraries. + +Currently used to keep kubernetes-python working with urllib3 2.x, which +removed HTTPResponse.getheaders(). Older kubernetes versions still call +getheaders when building ApiException objects. This shim reintroduces a +minimal getheaders that mirrors the previous behavior. +""" +from urllib3.response import HTTPResponse + + +def ensure_urllib3_getheaders() -> None: + """Add HTTPResponse.getheaders if urllib3 2.x removed it. + + Returns the header items as a list of (key, value) tuples, matching the + old http.client.HTTPResponse API used by kubernetes-python. + """ + if not hasattr(HTTPResponse, "getheaders"): + def _getheaders(self): # type: ignore[override] + return list(self.headers.items()) + + HTTPResponse.getheaders = _getheaders # type: ignore[attr-defined] \ No newline at end of file diff --git a/studio-backend/app/main.py b/studio-backend/app/main.py index ed54961..85c291b 100644 --- a/studio-backend/app/main.py +++ b/studio-backend/app/main.py @@ -1,5 +1,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware + +from app.compat import ensure_urllib3_getheaders + +# Restore HTTPResponse.getheaders expected by kubernetes-python when running with urllib3 2.x. +ensure_urllib3_getheaders() + from kubernetes import config # Load the kubeconfig file