@sparkelf/dsh-dataops-managed 0.3.24 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The custom `dataops-resources` sidebar tab.
3
+ *
4
+ * It renders the DataOps embed page in a same-origin iframe. The iframe is deliberately not
5
+ * a generic URL tab: the generic tab sandbox is an opaque origin and cannot use the portal's
6
+ * sessionStorage. The only parent-child messages are navigation and "add to conversation";
7
+ * every authorization decision still happens in DataOps.
8
+ *
9
+ * @module client/DataOpsResourcesTab
10
+ */
11
+ /** Minimal view of the tab record carried by Better Sidebar. */
12
+ export interface ResourceTabRecord {
13
+ meta?: unknown;
14
+ title?: string;
15
+ }
16
+ /** Props Better Sidebar passes to a tab component (the fields this tab needs). */
17
+ export interface DataOpsResourcesTabProps {
18
+ ctx: unknown;
19
+ scope?: {
20
+ sessionId?: string;
21
+ };
22
+ tab: ResourceTabRecord;
23
+ visible?: boolean;
24
+ }
25
+ /** Render the embedded DataOps resource page and translate its parent messages. */
26
+ export declare function DataOpsResourcesTab({ ctx, tab }: DataOpsResourcesTabProps): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Always-mounted host→client bridge for the resource tab.
3
+ *
4
+ * The Host tool cannot call the browser's tab API. It broadcasts one SSE event; this
5
+ * component translates that event into `betterSidebar.openTab` plus `updateTab` so a
6
+ * second import focuses the existing tab and refreshes its target resource.
7
+ *
8
+ * @module client/OpenResourceBridge
9
+ */
10
+ /** The part of Better Sidebar's service this bridge uses. */
11
+ export interface ResourceTabService {
12
+ openTab(seed: {
13
+ type: string;
14
+ id?: string;
15
+ title?: string;
16
+ meta?: unknown;
17
+ }, scope?: {
18
+ sessionId?: string;
19
+ }): void;
20
+ updateTab?(tabId: string, patch: {
21
+ title?: string;
22
+ path?: string;
23
+ meta?: unknown;
24
+ }, sessionId?: string): void;
25
+ }
26
+ /** Tab id shared with the client registration. */
27
+ export declare const RESOURCE_TAB_TYPE = "dataops-resources";
28
+ /** Invisible bridge component; renders nothing. */
29
+ export declare function OpenResourceBridge({ service, scope }: {
30
+ service: ResourceTabService;
31
+ scope?: {
32
+ sessionId?: string;
33
+ };
34
+ }): null;
@@ -14,7 +14,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
14
14
  'settings.dataops-managed': ManagedDataOpsKey;
15
15
  }
16
16
  }
17
- /** Required client services for the managed DataOps Settings section. */
17
+ /** Required client services for the managed DataOps Settings section and the resource tab bridge. */
18
18
  export declare const inject: string[];
19
19
  /**
20
20
  * Register the managed DataOps surfaces and the sign-in prompt.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DataOps spreadsheet-import transport for the collection skill.
3
+ *
4
+ * The host reads a workspace attachment, uploads it as multipart, asks DataOps for a
5
+ * create-table or existing-table plan, and only executes when the model calls the
6
+ * execute tool after the user's plan confirmation. Nothing here creates a table on
7
+ * preview/plan; execution is the single mutating call.
8
+ *
9
+ * @module collect-api
10
+ */
11
+ import type { ReviewApi } from './review-api.ts';
12
+ /** Largest spreadsheet the DataOps importer accepts. */
13
+ export declare const COLLECT_FILE_MAX_BYTES: number;
14
+ /** One importable DataOps database destination. */
15
+ export interface CollectDataSource {
16
+ urn: string;
17
+ name: string;
18
+ dbType: string | null;
19
+ databaseName: string | null;
20
+ }
21
+ /** The preview payload DataOps returns for one uploaded workbook. */
22
+ export interface CollectPreview {
23
+ sessionId: string;
24
+ file?: {
25
+ name?: string;
26
+ sheetName?: string;
27
+ rowCount?: number;
28
+ columnCount?: number;
29
+ };
30
+ sheetName?: string;
31
+ rowCount?: number;
32
+ columnCount?: number;
33
+ columns?: Array<Record<string, unknown>>;
34
+ [key: string]: unknown;
35
+ }
36
+ /** A create-table or field-mapping plan plus its session context. */
37
+ export interface CollectPlanResult {
38
+ session?: Record<string, unknown>;
39
+ model?: Record<string, unknown>;
40
+ plan?: Record<string, unknown>;
41
+ [key: string]: unknown;
42
+ }
43
+ /** The execute result; `resource` is the row DataOps upserted. */
44
+ export interface CollectExecuteResult {
45
+ success?: boolean;
46
+ mode?: string;
47
+ targetTable?: {
48
+ schema?: string;
49
+ table?: string;
50
+ };
51
+ totalRows?: number;
52
+ importedRows?: number;
53
+ skippedRows?: number;
54
+ errors?: unknown[];
55
+ resource?: {
56
+ id: number;
57
+ urn: string;
58
+ chineseName?: string | null;
59
+ accessLevel?: string;
60
+ aiVisible?: boolean;
61
+ created?: boolean;
62
+ };
63
+ [key: string]: unknown;
64
+ }
65
+ /** One resource row returned by the DataOps catalog. */
66
+ export interface CollectResourceRow {
67
+ id: number;
68
+ urn: string;
69
+ chineseName?: string | null;
70
+ sourceTable?: string | null;
71
+ resourceType?: string;
72
+ rowCount?: number | null;
73
+ accessLevel?: string;
74
+ aiVisible?: boolean | null;
75
+ }
76
+ /** Read and validate one spreadsheet attachment from the workspace. */
77
+ export declare const readCollectFile: (filePath: string) => Promise<{
78
+ buffer: Buffer;
79
+ fileName: string;
80
+ sizeBytes: number;
81
+ }>;
82
+ /** Spreadsheet import calls bound to one workspace credential. */
83
+ export declare class CollectApi {
84
+ private readonly reviewApi;
85
+ constructor(reviewApi: ReviewApi);
86
+ preview(filePath: string): Promise<CollectPreview>;
87
+ dataSources(): Promise<CollectDataSource[]>;
88
+ plan(payload: {
89
+ sessionId: string;
90
+ dataSourceUrn?: string;
91
+ targetTable?: {
92
+ schema: string;
93
+ table: string;
94
+ };
95
+ modelId?: number;
96
+ }): Promise<CollectPlanResult>;
97
+ execute(payload: Record<string, unknown>): Promise<CollectExecuteResult>;
98
+ listResources(query?: string, limit?: number): Promise<CollectResourceRow[]>;
99
+ }
@@ -64,6 +64,14 @@ interface VolatileRef<T> {
64
64
  export interface Config {
65
65
  /** DataOps browser/API origin reachable from the DSH Host. */
66
66
  baseUrl: string;
67
+ /**
68
+ * Browser-facing DataOps origin used by the resource sidebar iframe.
69
+ *
70
+ * `baseUrl` may point at a container-internal API address. Leave this empty in a
71
+ * same-origin portal deployment (the request Host is the correct origin) and set it
72
+ * only for a split-origin deployment where the browser reaches DataOps elsewhere.
73
+ */
74
+ webBaseUrl?: string;
67
75
  /** Local namespace for DataOps MCP tools. */
68
76
  serverName: string;
69
77
  /** DSH credential reference that stores the current DataOps access JWT. */
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Host side of the "open a DataOps resource tab" bridge.
3
+ *
4
+ * A model tool cannot call a browser tab API. The tool publishes one event on this
5
+ * workspace-local SSE channel; the client half owns `betterSidebar.openTab`. The channel
6
+ * is scoped to one managed workspace (one DataOps user) and carries navigation only —
7
+ * authorization still happens at every DataOps request.
8
+ *
9
+ * @module resource-tab
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from 'node:http';
12
+ /** SSE route the client subscribes to. */
13
+ export declare const OPEN_RESOURCE_EVENTS_PATH = "/integrations/dataops/open-resource/events";
14
+ /** Same-origin route the client uses to learn where the DataOps frontend lives. */
15
+ export declare const RESOURCE_TAB_CONFIG_PATH = "/integrations/dataops/resource-tab/config";
16
+ /** Browser tab type registered by the client half. */
17
+ export declare const RESOURCE_TAB_TYPE = "dataops-resources";
18
+ /** Modes the embed page knows how to apply after opening. */
19
+ export type OpenResourceMode = 'focus' | 'grant' | 'detail' | 'audit';
20
+ /** One navigation request delivered to every connected client. */
21
+ export interface OpenResourceEvent {
22
+ type: 'open_resource';
23
+ requestId: string;
24
+ resourceId: number;
25
+ resourceName: string | null;
26
+ mode: OpenResourceMode;
27
+ nonce: string;
28
+ }
29
+ /** Minimal logger surface used by the hub. */
30
+ interface HubLogger {
31
+ warn(message: string): void;
32
+ }
33
+ /**
34
+ * Fan-out registry for the workspace's live SSE subscribers.
35
+ *
36
+ * A workspace has one browser in the normal case, but a reconnect can briefly leave the
37
+ * previous response open. Broadcasting to the current set makes the new connection and the
38
+ * stale one harmless: the browser keeps one tab, and the client dedupes by resource tab id.
39
+ */
40
+ export declare class OpenResourceHub {
41
+ private readonly logger;
42
+ private readonly clients;
43
+ private heartbeat;
44
+ constructor(logger: HubLogger);
45
+ /** Accept one SSE subscription and own its response until the client disconnects. */
46
+ subscribe(request: IncomingMessage, response: ServerResponse): void;
47
+ /** Send one navigation event to every connected client. */
48
+ broadcast(event: Omit<OpenResourceEvent, 'type' | 'requestId'> & {
49
+ requestId?: string;
50
+ }): OpenResourceEvent;
51
+ /** Number of live subscribers, used by the tool result to explain a missing browser. */
52
+ size(): number;
53
+ /** Close every subscriber; called by the plugin disposer. */
54
+ dispose(): void;
55
+ private ensureHeartbeat;
56
+ }
57
+ /** Resolve the browser-facing DataOps origin for the iframe. */
58
+ export declare const resolveResourceTabConfig: (request: IncomingMessage, configuredWebBaseUrl: string | undefined) => {
59
+ embedBaseUrl: string;
60
+ sameOrigin: boolean;
61
+ tabType: string;
62
+ };
63
+ export {};
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { Context } from '@deepseek-ai/cordis';
19
19
  import type { ReviewApi, ReviewApiResult } from './review-api.ts';
20
+ import type { OpenResourceHub } from './resource-tab.ts';
20
21
  import { type AiToolName, type DataOpsScope } from './tool-scope.ts';
21
22
  /**
22
23
  * Reconcile the managed tools with the current DataOps scope.
@@ -28,6 +29,7 @@ import { type AiToolName, type DataOpsScope } from './tool-scope.ts';
28
29
  export declare class AiToolManager {
29
30
  private readonly ctx;
30
31
  private readonly reviewApi;
32
+ private readonly collectHub;
31
33
  private readonly definitions;
32
34
  private readonly registrations;
33
35
  private readonly unsubscribeExpiry;
@@ -36,7 +38,7 @@ export declare class AiToolManager {
36
38
  private revision;
37
39
  private inflight;
38
40
  private disposed;
39
- constructor(ctx: Context, reviewApi: ReviewApi);
41
+ constructor(ctx: Context, reviewApi: ReviewApi, collectHub: OpenResourceHub);
40
42
  /** The scope the current registration set was derived from, when one has been read. */
41
43
  currentScope(): DataOpsScope | undefined;
42
44
  /** The tool names currently registered, for diagnostics and tests. */
@@ -113,6 +113,19 @@ export declare const AI_TOOL_ACCESS: {
113
113
  readonly dataops_api_docs: {
114
114
  readonly superAdminOnly: true;
115
115
  };
116
+ readonly dataops_collect_preview: {
117
+ readonly permission: "data:resource:manage";
118
+ };
119
+ readonly dataops_collect_plan: {
120
+ readonly permission: "data:resource:manage";
121
+ };
122
+ readonly dataops_collect_execute: {
123
+ readonly permission: "data:resource:manage";
124
+ };
125
+ readonly dataops_collect_resources: {
126
+ readonly permission: "data:resource:read";
127
+ };
128
+ readonly dataops_open_resource_tab: {};
116
129
  };
117
130
  /** Every managed tool name, in the order the manager considers them. */
118
131
  export declare const AI_TOOL_NAMES: AiToolName[];
@@ -20,6 +20,7 @@
20
20
  import type { Context } from '@deepseek-ai/cordis';
21
21
  import { type ToolDefinition } from '@deepseek-ai/dsh-tools';
22
22
  import type { ReviewApi } from './review-api.ts';
23
+ import type { OpenResourceHub } from './resource-tab.ts';
23
24
  import { type AiToolName, type DataOpsScope } from './tool-scope.ts';
24
25
  /** One managed tool together with the name the disclosure map uses. */
25
26
  export interface AiManagedTool {
@@ -38,4 +39,4 @@ export interface AiManagedTool {
38
39
  * @param readScope - Live accessor for the manager's current scope.
39
40
  * @returns One definition per managed tool name.
40
41
  */
41
- export declare function buildAiManagementTools(ctx: Context, reviewApi: ReviewApi, readScope: () => DataOpsScope | undefined): AiManagedTool[];
42
+ export declare function buildAiManagementTools(ctx: Context, reviewApi: ReviewApi, readScope: () => DataOpsScope | undefined, collectHub: OpenResourceHub): AiManagedTool[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-dataops-managed",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "DataOps JWT intake and credential-backed MCP tools for managed DeepSeek Harness workspaces.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -41,6 +41,7 @@
41
41
  "cordis.patch.yml",
42
42
  "skills/dataops-query/SKILL.md",
43
43
  "skills/dataops-query/i18n/zh.json",
44
+ "skills/dataops-collect/SKILL.md",
44
45
  "README.md",
45
46
  "LICENSE"
46
47
  ],
@@ -0,0 +1,177 @@
1
+ ---
2
+ name: dataops-collect
3
+ description: 把表格 / Excel / CSV 建成可问数的数据资源,或把新表格追加到已有资源时使用。包含新建与追加的分岔、计划确认、建表写入、权限入口和结果汇报;确认计划前绝不建表。
4
+ ---
5
+
6
+ # 数据采集(AI 建资源)
7
+
8
+ 用户把一份表格交给 AI,AI 解析、出计划、用户确认后建表写数,最后得到一个能问数的资源。第一版只做一次性导入;一个会话一份文件、一个目标资源。
9
+
10
+ ## 一、什么时候用
11
+
12
+ 用户表达下面任一意图时使用本技能:
13
+
14
+ - 把这份表格建成能问数的资源
15
+ - 这个 Excel 帮我导进来
16
+ - 上传个表能问数吗
17
+ - 把这份数据加到已有的那个资源里
18
+ - 追加到 XX 资源
19
+ - 新建一个数据资源
20
+ - 表格导入 / 导入成表 / 建成资源
21
+ - 把 CSV 变成能查的表 / 能问数的表
22
+ - 这份数据帮我入库 / 建表
23
+ - 把这个 sheet 建成资源
24
+ - 这个表以后能用来问数吗
25
+ - 把这份销售表导入
26
+ - 把这份名单建成资源
27
+ - 加到门店销售明细里
28
+ - 追加一份月度数据
29
+ - 建个新资源,用这个文件
30
+ - 导入到数据中台
31
+ - 这个文件能建成资源吗
32
+ - 把附件建成资源
33
+ - 用这个表格建一张表
34
+
35
+ 不适用:纯问数、取数、查数据(用 `dataops-query`);只讨论表结构但不导入。
36
+
37
+ ## 二、四个硬规则
38
+
39
+ 1. **一次会话一份文件、一个目标资源。** 多文件批量不做。
40
+ 2. **有引用 = 追加;无引用 = 先问,不默认新建。** 用户话里已经说清“新建”或“追加”的,跳过这一问;没说清又没引用,必须先出「新建资源 / 选择已有资源」二选一,不能直接出新建计划。
41
+ 3. **计划确认是唯一硬闸。** 用户点「开始导入」之前,绝不调用 `dataops_collect_execute`,绝不建表、绝不写数。预览和计划工具本身不建表,可以放心调用。
42
+ 4. **执行走原生工具流。** 不要自己造执行卡、进度卡;工具参数、结果、耗时、失败原因都在原生工具卡里。你只在旁边说人话。
43
+
44
+ ## 三、主流程
45
+
46
+ ### 1. 找到文件
47
+
48
+ 从当前消息的附件 / 系统上下文里取得表格在工作区的路径(通常是 `/workspace/...` 或 `temp/conversations/.../attachments/...`)。没有路径时,不要猜目录、不要扫全工作区;直接说“请把表格拖进输入框,或点输入框的 + 上传”,并给一个内联上传提示。
49
+
50
+ ### 2. 预览,不要建表
51
+
52
+ 调用:
53
+
54
+ `dataops_collect_preview({ file_path })`
55
+
56
+ 返回 `sessionId`、`preview`、`dataSources`。用大白话复述:
57
+
58
+ > 看了下,明细 sheet 共 12,000 行、18 列;表头在第 2 行。我先按明细建资源。
59
+
60
+ 文件读不出来也在这里说清楚:
61
+
62
+ > 这个 xlsx 读不出来,可能加密了。你换个文件,或者告诉我表头在第几行。
63
+
64
+ ### 3. 先定去向
65
+
66
+ - 当前会话里有 `resource-ref` 引用块 → 追加到该资源;
67
+ - 用户说“新建 / 建个新资源 / 不要加到已有的” → 新建;
68
+ - 用户说“追加 / 加到 / 并到 / 合到” → 追加;没有具体资源时先让用户选;
69
+ - 都没有 → 出「新建资源 / 选择已有资源」GenUI,等用户选。
70
+
71
+ 选择已有资源时:
72
+ 1. 调用 `dataops_collect_resources({ query, limit: 8 })`;
73
+ 2. 用 `choices` 或 `list` + 按钮列出最多 8 个候选(资源名 + 表名 + 行数);
74
+ 3. 用户选中后,把该资源作为本次目标,进入计划。
75
+
76
+ ### 4. 生成计划,不要执行
77
+
78
+ 调用:
79
+
80
+ `dataops_collect_plan({ session_id, data_source_urn, target_schema?, target_table? })`
81
+
82
+ - 不传 `target_table` = 新建表(CREATE_TABLE);
83
+ - 传 `target_schema` + `target_table` = 追加到已有表(EXISTING_TABLE)。
84
+
85
+ 返回后检查 `plan`:
86
+ - 新建表:`plan.mode === 'CREATE_TABLE'`,有 `plan.createTable`、`plan.createSql`;
87
+ - 追加:`plan.mode === 'EXISTING_TABLE'`,有 `plan.mappings`,并在计划里多一行映射摘要(匹配 N 个字段、新增 N 个、N 个类型不兼容)。
88
+
89
+ ### 5. 出计划 GenUI
90
+
91
+ 计划是主确认点。必须包含:
92
+
93
+ - 资源名(AI 起草,可改)
94
+ - 目标表(原始层库表名,AI 起草,可改)
95
+ - 导入数据源(只有一个源时自动选中并置灰;多个源时让用户选)
96
+ - 字段总数,以及哪几个类型拿不准(标黄 / 写“需确认”)
97
+ - 去向(新建资源 / 追加到某个已有资源)
98
+ - 描述与标签(AI 起草,可改)
99
+ - 默认权限(仅自己可见)
100
+
101
+ 底部只有一组主次按钮:**开始导入**(`submit`,提交资源名/表名/描述)和 **再调整**(`button`)。不要做第二个确认步骤。
102
+
103
+ 计划 GenUI 示例(把 `<...>` 换成真实值):
104
+
105
+ ```dsh-ui
106
+ {"title":"导入计划","items":[{"type":"text","size":"body","content":"看了下,明细 sheet 共 <行数> 行、<列数> 列;我先按明细建资源。"},{"type":"card","title":"<资源名>","items":[{"type":"keyvalue","pairs":[{"key":"去向","value":"新建资源"},{"key":"导入数据源","value":"<数据源名>"},{"key":"目标表","value":"<schema.table>"},{"key":"字段","value":"<字段数> 列(<需确认数> 列类型需确认)"},{"key":"默认权限","value":"仅自己可见"}]},{"type":"table","columns":["字段","类型","说明"],"rows":[["<字段1>","<类型>","<说明>"],["<字段2>","<类型>","需确认"]]},{"type":"input","id":"resourceName","label":"资源名","value":"<资源名>"},{"type":"input","id":"tableName","label":"目标表","value":"<目标表>"},{"type":"textarea","id":"description","label":"描述与标签","value":"<描述>"},{"type":"submit","label":"开始导入","action":"dataops_collect_confirm","groups":["resourceName","tableName","description"]},{"type":"button","label":"再调整","tone":"ghost","action":"dataops_collect_adjust"}]}]}
107
+ ```
108
+
109
+ 规则:
110
+
111
+ - 用户点「再调整」后,只在对话里改参数,然后重新调用 `dataops_collect_plan` / 修改计划,**不要执行**;
112
+ - 用户改了参数,重新出计划,不直接执行;
113
+ - 确认后,计划折叠成一行“已按此计划执行”的回执,把屏幕让给工具流;
114
+ - 确认后如果要改参数,先停,再出新计划。
115
+
116
+ ### 6. 执行
117
+
118
+ 用户点「开始导入」后,从 GenUI 的 `fields` 取 `resourceName`、`tableName`、`description`,然后调用:
119
+
120
+ `dataops_collect_execute({ session_id, data_source_urn, mode, target_schema?, target_table?, create_plan_json, mappings_json, resource_name, resource_description, resource_tags?, ai_visible?, access_level? })`
121
+
122
+ - `mode` 用 `plan.mode`;
123
+ - 新建表:`create_plan_json` 传 `JSON.stringify(plan.createTable)`,`mappings_json` 传 `"[]"`;
124
+ - 追加:`mappings_json` 传 `JSON.stringify(plan.mappings)`;
125
+ - `resource_name` 用用户确认后的资源名;
126
+ - `access_level` 默认 `PRIVATE`;`ai_visible` 默认 `true`。
127
+
128
+ 执行期间只说阶段小结,例如:
129
+
130
+ > 表建好了。现在写数据,一万两千行,估计一两分钟。
131
+ >
132
+ > 写完了,正在生成字段说明。
133
+
134
+ 失败只说三类,并给一个动作:
135
+ - 文件问题 → 重新上传 / 告诉我表头在第几行;
136
+ - 字段问题 → 调整映射 / 改类型;
137
+ - 系统问题 → 重试。
138
+
139
+ ### 7. 结果 GenUI + 打开资源页签
140
+
141
+ 执行成功后:
142
+
143
+ 1. 调用 `dataops_open_resource_tab({ resource_id, resource_name, mode: 'focus' })`,让右侧栏自动打开 `dataops-resources` 页签并定位新资源;
144
+ 2. 出结果 GenUI,必须包含资源名、目标表、行数、字段数、“已可问数”、当前可见范围(默认“仅自己可见”),以及三个动作:**分配权限 / 去问数 / 查看详情**,外加一条 **查看操作记录** 文字链。
145
+
146
+ 结果 GenUI 示例:
147
+
148
+ ```dsh-ui
149
+ {"title":"导入完成","items":[{"type":"callout","tone":"success","title":"已可问数","content":"资源已建成,可以开始问数。"},{"type":"keyvalue","pairs":[{"key":"资源名","value":"<资源名>"},{"key":"目标表","value":"<schema.table>"},{"key":"行数","value":"<行数>"},{"key":"字段数","value":"<字段数>"},{"key":"可见范围","value":"仅自己可见"}]},{"type":"row","items":[{"type":"button","label":"分配权限","tone":"primary","action":"dataops_open_grant"},{"type":"button","label":"去问数","action":"dataops_ask"},{"type":"button","label":"查看详情","action":"dataops_open_detail"}]},{"type":"button","label":"查看操作记录 →","tone":"ghost","small":true,"action":"dataops_open_audit"}]}
150
+ ```
151
+
152
+ 三个动作的处理:
153
+
154
+ - 分配权限 → `dataops_open_resource_tab({ resource_id, mode: 'grant' })`,侧栏资源卡定位并自动打开授权弹窗;
155
+ - 去问数 → 不要跳页,直接在对话里给 2-3 个可以问的问题示例,并说明资源已可被检索;
156
+ - 查看详情 → `dataops_open_resource_tab({ resource_id, mode: 'detail' })`;
157
+ - 查看操作记录 → `dataops_open_resource_tab({ resource_id, mode: 'audit' })`。
158
+
159
+ ## 四、引用资源(追加)
160
+
161
+ 引用可能来自输入框的资源 chip,也可能来自右侧栏资源卡的「添加到对话」。如果引用以结构化块出现,形如:
162
+
163
+ ```resource-ref
164
+ {"kind":"dataops-resource","resourceId":123,"name":"门店销售明细","table":"dwd_store_sales"}
165
+ ```
166
+
167
+ 就把它当作本次追加目标,不要再问“新建还是追加”。如果用户在上传文件之前先引用了资源,直接按追加处理。
168
+
169
+ 如果「添加到对话」降级成发送一条消息,消息里必须带这个 `resource-ref` 块;没有结构化块时,按“用户口头提到资源名”处理,先用 `dataops_collect_resources` 找到资源再确认,不要凭名字猜 id。
170
+
171
+ ## 五、边界
172
+
173
+ - 不做定时采集、增量水位、失败重试策略;
174
+ - 不做多文件批量、文件夹上传;
175
+ - 不做覆盖(只追加);
176
+ - 不在对话里做权限表单(GenUI 没有人员选择器);
177
+ - 不在 DSH 里做审计弹窗;操作记录在 DataOps 资源详情 / 系统审计。