mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
* feat(sessions): let an opener name the artifact version to show A tab already remembers the version a reader pinned, and re-pointing it keeps that pin. Plan mode needs the two intents that leaves out: a plan card scrolled up the transcript wants the version it proposed, and a plan going up for approval wants the current text with no pin at all. `ArtifactVersionTarget` is those two alongside the existing one: a number, `'latest'`, or omitted. Omitted still cannot double as `'latest'` — every artifact tool re-opens the document it just wrote, so taking that as a request to move would yank a reader out of the version they chose on every edit the agent makes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): add the plan-mode gate and tag plan-mode-safe tools Plan mode is a read-only posture, so something has to decide which tools it may still run. `Tool.planModeSafe` is that tag, and processToolCall fails closed on it: untagged means mutating means blocked. Deriving it from `requiresConfirmation` was not an option — unconfirmed mutating tools exist, and a posture that leaks one is not a posture. The gate runs twice per call. Before `validateBeforeConfirmation`, so a validator cannot reach out while planning; and again after the confirmation wait, because plan mode can be entered while a mutating tool's card is already pending, and that approval must not carry it through. Arguments are read one field at a time rather than through a parse of the whole call. `change_note` is optional and cosmetic, and a model that sends it as `null` would otherwise fail the object parse and take the plan down with it — the user being told there was no plan to approve, which is false. Also here, because refusing a call well needs them: a validator may now return the row the user reads and the result the model gets separately, a tool may word its own cancellation, and a tool may start work when its card appears rather than when it is approved. The gate is consulted before any of them. `shouldAutoAcceptToolConfirmations` is asked about the tool by name, because skipping the confirmation wait is itself an answer on the user's behalf and one tool must not be answered for. Deciding that without the name would put the exception out of reach of the only path that needs it. The gate stays inert until a chat supplies `isPlanModeActive`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): give a session one versioned plan document The plan the user agrees to has to survive `/clear`, so it belongs to the session rather than the conversation, and a session holds exactly one. Its id is the session's, so the primary key is the constraint — there is no second row to mint, no index to maintain and no schema change at all. Every write reads the row it is about to replace inside the transaction that replaces it. Read outside, two tabs both see version N, both stamp N+1, and the later write silently drops the earlier one's text and its snapshot; IndexedDB serialises readwrite transactions over a store, so read and write together cannot interleave. Approval takes the same route but patches only the pointer: an approval computed while another tab was revising must not carry this tab's older content back over the newer text. Approval is `approvedVersion`, a pointer at a version, never a flag. Below the current version means the newest text is a proposal the user has not agreed to; absent means nothing here was ever approved. Only exit_plan_mode can leave the pointer behind, since every write outside plan mode carries it forward — an amendment the user's posture already trusts is still the agreed plan. Declining writes nothing at all: the refused proposal stands as the newest version, with the agreed one still in history. Nor can create_artifact confer approval. It asks for no confirmation, so the model writing a plan document is not the user agreeing to one; a plan written there holds the session's slot as a draft until a decision lands on it. That is also why the approved version is exempt from pruning. A plan approved at v1 and then planned against for twenty more rounds would otherwise lose the very version that stands as agreed, and with it the card that opens it, the banner offering it back, and read_artifact at that version. It is excluded from the pruning candidates rather than added on top, so the budget is unchanged and what survives simply stops being contiguous. The write reports whether the database took it. Most callers still degrade like the reads do, but a plan cannot: returning one the database refused would let the user approve and execute against a document that disappears on reload — a refused plan write raises instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): add plan mode — the posture and its two tools enter_plan_mode asks to hold work; exit_plan_mode hands over a plan and, on approval, gives the posture back to whatever preceded it. Both carry `planModeSafe`, since a posture with no exit is a trap. Only the transition the current posture allows is offered, so there is no tool for leaving a posture the chat is not in. A planning round runs from entering plan mode to the proposal the user decides on. It remembers only the write it made, because nothing it does is undone — and that write is shared between the card's confirmation hook and the tool's `fn`, so the plan is on screen while the user is deciding whether to approve it rather than after. The round is identified by an epoch bumped on *entering*, not by the conversation. A chat rotation mid-approval must still let that approval hand the posture back; a round the user has since left and re-entered must not, or approving the old plan would drop them out of a read-only posture they just chose. Saving a proposal revises the session's plan document and creates one only when there is none — both halves in a single transaction, so a second tab proposing at the same moment revises the row this one wrote rather than racing it. Persistence failures hold the posture. Approval is reported only once both the proposal and the approval pointer are durable, so a plan the database refused cannot unblock mutating tools. The failure is reported from `fn` and no earlier: the write settles while the card is still waiting to be confirmed, and clearing that card from underneath the wait would take away the only control that resolves it. An auto-accepting posture answers for the user through one predicate, asked by every path that answers: the pending-card sweep, the confirmation itself, and the decision to skip the wait at all. enter_plan_mode never qualifies: YOLO means "stop asking and run it", and a call from a tool set snapshotted before the switch must not answer that with a read-only posture — whether its card is already pending or has yet to be registered. Plan mode lives in its own controller with a narrow view of the chat it runs in: it reads that autonomy state and asks for the two changes it can cause, rather than owning any of it. Plan mode is offered only in a session chat, and a session chat is GLOBAL for its whole life. The gate reads that mode, so `changeMode` refuses to move one out of GLOBAL rather than resting the invariant on a picker being hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(copilot): surface plan mode in the chat and the artifact list Plan mode is the only posture that refuses work, so the composer says so before the user types the request it is about to turn down: the mode pill is tinted whole rather than by its icon, and the empty placeholder carries the constraint in words. Teal, not the house green — green is the transcript's success colour a few rows up, and a mode signal in it would read as "this worked" rather than "this is held". A blocked tool renders as its own lean row naming the tool, not as an error: the call did what plan mode says it should, and "why can't it edit" is answered where it is asked. A plan card names the decision — proposed, approved, or not approved — and never the button, since a Stop and a posture switch resolve it too. Its button opens the version that card proposed, so a card far up the transcript still shows the plan it put forward rather than whatever the document has become since. The artifact list and the preview header both label the plan through one badge helper, so the two cannot disagree about what counts as one: a plan the user never approved keeps the plan icon and takes the neutral badge, leaving the teal to mean exactly one thing. In the viewer, an unapproved revision says so in a bar that cannot be scrolled past, with the version the user did agree to one click away. The autonomy picker became a table with one row per posture, so adding one touches a single place instead of four parallel switch statements. A version of a plan is read against the one the user approved, not against the newest: latest is only where the model happened to stop. So the approved version is never stale — its bar is teal and points forward to the draft rather than warning about it — the version in front of it is the draft, and anything behind it is history that is neither and takes no pill at all. The list opens a plan at the approved version for the same reason, which is what lets its pill say `plan` while an unapproved draft sits at the head. One helper answers all of it, so the list and the preview header cannot drift apart on what counts as the plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ai-evals): exercise plan mode end to end A case a unit test cannot stand in for: it starts in plan mode against the real gate and the real exit_plan_mode, and grades whether the model researches and hands over a usable plan instead of guessing at one. The checklist does not grade what the harness does for the model — exit_plan_mode writes the plan document itself, so "saves the plan as an artifact" would pass on any run where the tool is called at all. The eval store seeds artifacts with history and mirrors the store's own approval rules, so a rename cannot promote a proposal the user turned down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ai-evals): import the plan-mode messages from the module that owns them `PLAN_MODE_MESSAGES` moved to `planModeMessages.ts`; `planMode.ts` imports it without re-exporting. Under vitest, which runs the frontend adapters, the stale import resolved to `undefined` rather than failing to link, so `global-planmode1-hands-over-a-plan` threw on the approval message after the posture had already been dropped and the tool withdrawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): state plan mode's constraint in neutral text The composer's two-tone placeholder becomes a plain "Read-only" beside the autonomy picker, next to where YOLO puts its own warning, and a blocked call's row drops the mode colour. Teal is left marking what the posture is — the badge, the version bars, the pill — rather than every call it refuses. ContextTextarea goes back to main with the accent: `placeholderAccent` had no other consumer, and the aria-label existed only because the accent blanked the native placeholder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): hold the plan header's verdict until the snapshot lands Opening a plan at the version its reader approved pins a version behind the head, and until that read resolves `shownVersion` is still the head — so the header wore the draft's badge and its orange "not approved" bar over the very case the pin exists to serve, then flipped. The header now says nothing while `restoringPin`, as the body already does. Judging `pinned` instead would print the approved signal over text that is still the draft, trading a true transient signal for a false one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(copilot): refuse a hand-over once plan mode has ended A response can carry two exit_plan_mode calls, and the tool list they run against is snapshotted before the first one restores the posture. The second then found the tool with plan mode already over: under YOLO every confirmation is answered for the user, so it wrote its own summary and stamped the user's approval on a plan no card had shown them. Refused in `validateBeforeConfirmation` rather than in `fn`, since `onConfirmationRequested` writes the document too. The maintenance path is untouched — a plan still gets revised outside the posture with update_artifact, which is what the tool's own description already tells the model to use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { z } from 'zod'
|
||
import { WorkspaceService, type CompletedJob } from '$lib/gen'
|
||
import type { DataTableTables } from '$lib/gen/types.gen'
|
||
import { runScript } from '$lib/components/jobs/utils'
|
||
import {
|
||
createToolDef,
|
||
executeTestRun,
|
||
type Tool,
|
||
type ToolDisplayMessage,
|
||
type ChatJobResultFormat
|
||
} from './shared'
|
||
|
||
/**
|
||
* Workspace-scoped datatable tools, with no app whitelist and no creation policy.
|
||
*
|
||
* Datatables are workspace-level managed PostgreSQL databases. The backend
|
||
* endpoints used here (`list_datatable_tables`, `get_datatable_table_schema`)
|
||
* and SQL execution (`datatable://<name>`) are gated only by workspace
|
||
* membership, so these tools need no app context and operate directly on the
|
||
* workspace. This is the unrestricted counterpart to the app-mode datatable
|
||
* tools in `app/core.ts`, which additionally filter by the app's whitelist.
|
||
*/
|
||
|
||
// ============= Utility =============
|
||
|
||
/** Memoize a factory function - the factory is only called once, on first access */
|
||
const memo = <T>(factory: () => T): (() => T) => {
|
||
let cached: T | undefined
|
||
return () => (cached ??= factory())
|
||
}
|
||
|
||
// ============= Pure workspace-scoped operations =============
|
||
|
||
/** List all datatables configured in the workspace, with their schema/table names. */
|
||
export async function listDatatables(workspace: string): Promise<DataTableTables[]> {
|
||
return await WorkspaceService.listDataTableTables({ workspace })
|
||
}
|
||
|
||
/** Get the columns (column_name -> compact_type) of one datatable table. */
|
||
export async function getDatatableColumns(
|
||
workspace: string,
|
||
datatableName: string,
|
||
schemaName: string,
|
||
tableName: string
|
||
): Promise<Record<string, string>> {
|
||
const schema = await WorkspaceService.getDataTableTableSchema({
|
||
workspace,
|
||
datatableName,
|
||
schemaName,
|
||
tableName
|
||
})
|
||
return schema.columns
|
||
}
|
||
|
||
// ============= Error helpers =============
|
||
|
||
/**
|
||
* The backend returns "datatable <name> not found" when no datatable with that
|
||
* name is configured in the workspace settings. That is a hard, blocking
|
||
* prerequisite (not a transient failure), so we surface an explicit, actionable
|
||
* message instead of the raw internal error.
|
||
*/
|
||
function isDatatableNotConfiguredError(error: string | null | undefined): boolean {
|
||
return typeof error === 'string' && /datatable\s+\S+\s+not found/i.test(error)
|
||
}
|
||
|
||
function datatableNotConfiguredMessage(datatableName: string): string {
|
||
return (
|
||
`Datatable "${datatableName}" is not configured in this workspace, so this operation cannot run. ` +
|
||
`Datatables are not created by SQL — they must be set up first by the user in the workspace settings ` +
|
||
`(Workspace settings → Data Tables) before any table can be queried or created. ` +
|
||
`This is a required, blocking prerequisite: do not retry on this or another name. ` +
|
||
`Tell the user they need to configure a datatable (e.g. named "${datatableName}") in their workspace settings, then try again.`
|
||
)
|
||
}
|
||
|
||
const NO_DATATABLES_CONFIGURED_MESSAGE =
|
||
'No datatables are configured in this workspace. Datatable operations (querying data, creating or altering tables) are blocked until a datatable exists. ' +
|
||
'Datatables are not created by SQL — the user must set one up in the workspace settings (Workspace settings → Data Tables) first. ' +
|
||
'Do not call exec_datatable_sql or assume a "main" datatable exists; instead tell the user this is a required prerequisite and ask them to configure a datatable in their workspace settings.'
|
||
|
||
// ============= Tool definitions =============
|
||
|
||
const getListDatatablesSchema = memo(() => z.object({}))
|
||
const getListDatatablesToolDef = memo(() =>
|
||
createToolDef(
|
||
getListDatatablesSchema(),
|
||
'list_datatables',
|
||
'List datatables configured in the workspace with schema and table names only. Does not include column definitions. Use this directly for table-list or available-tables summaries. Only call get_datatable_table_schema when column names/types are required.'
|
||
)
|
||
)
|
||
|
||
const getGetDatatableTableSchemaSchema = memo(() =>
|
||
z.object({
|
||
datatable_name: z.string().describe('The datatable name to inspect, e.g. "main".'),
|
||
schema_name: z.string().describe('The schema name, e.g. "public".'),
|
||
table_name: z.string().describe('The table name to inspect.')
|
||
})
|
||
)
|
||
const getGetDatatableTableSchemaToolDef = memo(() =>
|
||
createToolDef(
|
||
getGetDatatableTableSchemaSchema(),
|
||
'get_datatable_table_schema',
|
||
'Get column definitions for one datatable table. Do not call this for row counts or table-list summaries; list_datatables is enough for those.'
|
||
)
|
||
)
|
||
|
||
const getExecDatatableSqlSchema = memo(() =>
|
||
z.object({
|
||
datatable_name: z
|
||
.string()
|
||
.describe(
|
||
'The name of the datatable to query (e.g., "main"). Must be one of the datatables configured in the workspace.'
|
||
),
|
||
sql: z
|
||
.string()
|
||
.describe(
|
||
'The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.'
|
||
),
|
||
background: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
'Run in the background without waiting. Set true for queries you expect to be slow (large scans, heavy migrations) — you will be notified when it finishes. Leave unset for normal queries, which wait briefly and only background automatically if slow.'
|
||
),
|
||
wait_seconds: z
|
||
.number()
|
||
.optional()
|
||
.describe(
|
||
'How many seconds to wait for the query in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a query you expect to finish in ~30–60s and want the result in this same turn. Ignored when background is true.'
|
||
)
|
||
})
|
||
)
|
||
const getExecDatatableSqlToolDef = memo(() =>
|
||
createToolDef(
|
||
getExecDatatableSqlSchema(),
|
||
'exec_datatable_sql',
|
||
'Execute a SQL query on a workspace datatable. Use this to explore data, test queries, create/alter tables, or make changes. Creating a table is a normal CREATE TABLE statement — no registration step is needed.'
|
||
)
|
||
)
|
||
|
||
/** Maximum rows returned to the model for a SELECT query. */
|
||
const MAX_ROWS = 100
|
||
|
||
/**
|
||
* Shape a finished datatable SQL job into the model-visible result + tool card:
|
||
* SELECT rows capped at MAX_ROWS, DDL/DML reported as zero rows, and the "datatable
|
||
* not configured" backend error rewritten as the actionable blocking message.
|
||
*
|
||
* Pure and keyed only on the datatable name so it can run from BOTH the inline
|
||
* completion path (executeTestRun.formatCompletion) AND a detached/rehydrated job
|
||
* reconstructed from its persisted ChatJobResultFormat (see formatChatJobCompletion).
|
||
*/
|
||
export function formatDatatableSqlCompletion(
|
||
job: CompletedJob,
|
||
datatableName: string
|
||
): { llmText: string; card: Partial<ToolDisplayMessage> } {
|
||
if (job.success) {
|
||
// Successful runs always carry a `result` array (empty for DDL/DML),
|
||
// so SELECT rows and zero-row statements share one reporting path.
|
||
const rows = Array.isArray(job.result) ? (job.result as Record<string, any>[]) : []
|
||
const rowCount = rows.length
|
||
const payload =
|
||
rowCount > MAX_ROWS
|
||
? {
|
||
success: true,
|
||
rowCount,
|
||
result: rows.slice(0, MAX_ROWS),
|
||
note: `Showing first ${MAX_ROWS} of ${rowCount} rows`
|
||
}
|
||
: { success: true, rowCount, result: rows }
|
||
return {
|
||
llmText: JSON.stringify(payload, null, 2),
|
||
card: {
|
||
content: `Query returned ${rowCount} row(s)`,
|
||
result: JSON.stringify(job.result, null, 2)
|
||
}
|
||
}
|
||
}
|
||
const raw =
|
||
(job.result as any)?.error?.message ??
|
||
(typeof job.result === 'string' ? job.result : JSON.stringify(job.result)) ??
|
||
// JSON.stringify(undefined) is `undefined` (not a string), so a failed
|
||
// job with no result would otherwise yield an empty error message.
|
||
'Unknown error'
|
||
const errorMsg = isDatatableNotConfiguredError(raw)
|
||
? datatableNotConfiguredMessage(datatableName)
|
||
: raw
|
||
return {
|
||
llmText: JSON.stringify({ success: false, error: errorMsg }),
|
||
card: { content: `Error: ${errorMsg}`, error: errorMsg }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reconstruct a tool's terminal formatter from the serializable descriptor stored
|
||
* on a ChatJob. Lets a background job that detached (and may have survived a reload,
|
||
* losing any in-memory closure) still report through its launching tool's result
|
||
* contract. Dispatches on `kind`; extend as more tools gain persisted formatters.
|
||
*/
|
||
export function formatChatJobCompletion(
|
||
job: CompletedJob,
|
||
resultFormat: ChatJobResultFormat
|
||
): { llmText: string; card: Partial<ToolDisplayMessage> } {
|
||
switch (resultFormat.kind) {
|
||
case 'datatable':
|
||
return formatDatatableSqlCompletion(job, resultFormat.datatableName)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The unrestricted workspace datatable tools, for registration in global mode.
|
||
* Helper-free: each tool reads `workspace` directly from the tool call params.
|
||
*/
|
||
export function getDatatableTools(): Tool<{}>[] {
|
||
return [
|
||
{
|
||
def: getListDatatablesToolDef(),
|
||
planModeSafe: true,
|
||
fn: async ({ workspace, toolId, toolCallbacks }) => {
|
||
toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' })
|
||
try {
|
||
const metadata = await listDatatables(workspace)
|
||
if (metadata.length === 0) {
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: 'No datatables configured — set one up in workspace settings'
|
||
})
|
||
return NO_DATATABLES_CONFIGURED_MESSAGE
|
||
}
|
||
const totalTables = metadata.reduce(
|
||
(acc, datatable) =>
|
||
acc +
|
||
Object.values(datatable.schemas).reduce((sum, tables) => sum + tables.length, 0),
|
||
0
|
||
)
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Listed ${metadata.length} datatable(s) with ${totalTables} table(s)`
|
||
})
|
||
return JSON.stringify(metadata, null, 2)
|
||
} catch (e) {
|
||
const errorMsg = `Error listing datatables: ${e instanceof Error ? e.message : String(e)}`
|
||
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
|
||
return errorMsg
|
||
}
|
||
}
|
||
},
|
||
{
|
||
def: getGetDatatableTableSchemaToolDef(),
|
||
planModeSafe: true,
|
||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||
const parsedArgs = getGetDatatableTableSchemaSchema().parse(args)
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Getting schema for ${parsedArgs.datatable_name}.${parsedArgs.schema_name}.${parsedArgs.table_name}...`
|
||
})
|
||
try {
|
||
const columns = await getDatatableColumns(
|
||
workspace,
|
||
parsedArgs.datatable_name,
|
||
parsedArgs.schema_name,
|
||
parsedArgs.table_name
|
||
)
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Retrieved schema for ${parsedArgs.schema_name}.${parsedArgs.table_name}`
|
||
})
|
||
return JSON.stringify(
|
||
{
|
||
datatable_name: parsedArgs.datatable_name,
|
||
schema_name: parsedArgs.schema_name,
|
||
table_name: parsedArgs.table_name,
|
||
columns
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
} catch (e) {
|
||
const raw = e instanceof Error ? e.message : String(e)
|
||
const errorMsg = isDatatableNotConfiguredError(raw)
|
||
? datatableNotConfiguredMessage(parsedArgs.datatable_name)
|
||
: `Error getting table schema: ${raw}`
|
||
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
|
||
return errorMsg
|
||
}
|
||
}
|
||
},
|
||
{
|
||
def: getExecDatatableSqlToolDef(),
|
||
requiresConfirmation: true,
|
||
confirmationMessage: 'Execute SQL on datatable',
|
||
showDetails: true,
|
||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||
const parsedArgs = getExecDatatableSqlSchema().parse(args)
|
||
const name = parsedArgs.datatable_name
|
||
// Route through executeTestRun so a slow query detaches into the jobs
|
||
// tray (and honors `background`) instead of blocking the chat turn,
|
||
// while keeping the datatable-specific result/error shaping.
|
||
return executeTestRun({
|
||
jobStarter: () =>
|
||
runScript({
|
||
workspace,
|
||
requestBody: {
|
||
language: 'postgresql',
|
||
content: parsedArgs.sql,
|
||
args: { database: `datatable://${name}` }
|
||
}
|
||
}),
|
||
workspace,
|
||
toolCallbacks,
|
||
toolId,
|
||
contextName: 'script',
|
||
label: `SQL · ${name}`,
|
||
background: parsedArgs.background,
|
||
detachAfterMs:
|
||
parsedArgs.wait_seconds == null
|
||
? undefined
|
||
: Math.max(0, parsedArgs.wait_seconds) * 1000,
|
||
startMessage: `Executing SQL on "${name}"...`,
|
||
runningMessage: `SQL running on "${name}"...`,
|
||
// Inline path shapes the result here; the descriptor mirrors it so a
|
||
// detached/rehydrated completion reconstructs the same contract.
|
||
formatCompletion: (job) => formatDatatableSqlCompletion(job, name),
|
||
resultFormat: { kind: 'datatable', datatableName: name }
|
||
})
|
||
}
|
||
}
|
||
]
|
||
}
|