吸收数字员工产物访问治理
This commit is contained in:
@@ -2171,10 +2171,18 @@
|
||||
.de-workday-result-summary { margin: 0; color: #173550; font-size: 13px; line-height: 1.55; }
|
||||
.de-artifact-pills { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.de-artifact-source-list { display: grid; gap: 8px; max-height: 320px; overflow-y: auto; padding-right: 4px; }
|
||||
.de-artifact-source-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(88px, 140px); gap: 4px 10px; align-items: center; padding: 9px 10px; border-radius: 8px; border: 1px solid rgba(79,172,254,0.12); background: rgba(244,250,255,0.62); }
|
||||
.de-artifact-source-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(88px, 140px) auto; gap: 4px 10px; align-items: center; padding: 9px 10px; border-radius: 8px; border: 1px solid rgba(79,172,254,0.12); background: rgba(244,250,255,0.62); }
|
||||
.de-artifact-source-row strong { min-width: 0; color: var(--de-text-primary); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.de-artifact-source-row span { color: var(--de-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
||||
.de-artifact-source-row code, .de-artifact-source-row em { grid-column: 1 / -1; min-width: 0; color: var(--de-text-body); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.de-artifact-source-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||
.de-icon-btn { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid rgba(79,172,254,0.18); border-radius: 6px; color: #275a82; background: rgba(255,255,255,0.78); cursor: pointer; }
|
||||
.de-icon-btn:hover:not(:disabled) { border-color: rgba(79,172,254,0.45); background: #fff; }
|
||||
.de-icon-btn:disabled { cursor: not-allowed; opacity: 0.42; }
|
||||
.de-inline-notice { margin: 8px 0 0; color: var(--de-text-muted); font-size: 12px; }
|
||||
.de-artifact-preview { margin-top: 10px; border: 1px solid rgba(79,172,254,0.14); border-radius: 8px; background: rgba(255,255,255,0.78); overflow: hidden; }
|
||||
.de-artifact-preview > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-bottom: 1px solid rgba(79,172,254,0.1); }
|
||||
.de-artifact-preview pre { max-height: 220px; margin: 0; padding: 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--de-text-body); font-size: 12px; line-height: 1.55; }
|
||||
.de-decision-card { background: rgba(247,251,255,0.96); }
|
||||
.de-decision-chain-note {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
|
||||
@@ -723,6 +723,22 @@ export function getArtifacts(): Promise<any[]> {
|
||||
.then((data) => data.artifacts ?? []);
|
||||
}
|
||||
|
||||
export function accessArtifact(artifactId: string, action: 'preview' | 'download' | 'open_location'): Promise<any> {
|
||||
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/access`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getArtifactAccessEvents(params: Record<string, string | number | undefined> = {}): Promise<any> {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== '') search.set(key, String(value));
|
||||
}
|
||||
const suffix = search.toString() ? `?${search.toString()}` : '';
|
||||
return apiFetch(`/api/digital-employee/artifact-access-events${suffix}`);
|
||||
}
|
||||
|
||||
export function getDebugConversations(): Promise<any[]> {
|
||||
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
|
||||
.then((data) => data.conversations ?? []);
|
||||
|
||||
@@ -63,6 +63,7 @@ declare global {
|
||||
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
||||
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
|
||||
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
|
||||
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
||||
};
|
||||
@@ -237,6 +238,17 @@ interface QimingclawDailyReportRecordResult {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
interface QimingclawArtifactAccessResponse {
|
||||
ok: boolean;
|
||||
artifact_id: string;
|
||||
action: 'preview' | 'download' | 'open_location';
|
||||
status: 'allowed' | 'rejected';
|
||||
error?: string;
|
||||
reason_codes?: string[];
|
||||
preview?: Record<string, unknown>;
|
||||
event_id?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawManagedServiceActionResponse {
|
||||
ok: boolean;
|
||||
service_id: string;
|
||||
@@ -659,6 +671,15 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
const rows = filterSkillCallAuditRows(skillCallAuditRows(await readQimingclawSnapshot()), url.searchParams);
|
||||
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/digital-employee/artifact-access-events') {
|
||||
const rows = filterArtifactAccessRows(artifactAccessRows(await readQimingclawSnapshot()), url.searchParams);
|
||||
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
|
||||
}
|
||||
const artifactAccessMatch = pathname.match(/^\/api\/(?:digital-employee\/artifacts|artifact)\/([^/]+)\/access$/);
|
||||
if (method === 'POST' && artifactAccessMatch) {
|
||||
const artifactId = decodeURIComponent(artifactAccessMatch[1] || '');
|
||||
return await handleArtifactAccess(artifactId, parseOptionalJsonBody(options.body)) as T;
|
||||
}
|
||||
const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/);
|
||||
if (method === 'GET' && businessPlanDetailMatch) {
|
||||
const planId = decodeURIComponent(businessPlanDetailMatch[1] || '');
|
||||
@@ -1351,6 +1372,44 @@ async function handleManagedServiceRestart(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArtifactAccess(
|
||||
artifactId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<QimingclawArtifactAccessResponse> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
const action = normalizeArtifactAccessAction(stringValue(body.action));
|
||||
if (!bridge?.accessArtifact) {
|
||||
return {
|
||||
ok: false,
|
||||
artifact_id: artifactId,
|
||||
action,
|
||||
status: 'rejected',
|
||||
error: 'bridge_unavailable',
|
||||
reason_codes: ['bridge_unavailable'],
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await bridge.accessArtifact(artifactId, action, {
|
||||
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||
source: stringValue(body.source) || 'sgrobot-digital-adapter',
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
artifact_id: artifactId,
|
||||
action,
|
||||
status: 'rejected',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reason_codes: ['artifact_access_failed'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArtifactAccessAction(value: string): 'preview' | 'download' | 'open_location' {
|
||||
if (value === 'download' || value === 'open_location') return value;
|
||||
return 'preview';
|
||||
}
|
||||
|
||||
function syncLine(snapshot: QimingclawSnapshot | null): string {
|
||||
const sync = snapshot?.sync;
|
||||
if (!sync) return '管理端同步:未连接';
|
||||
@@ -2368,6 +2427,43 @@ function filterSkillCallAuditRows(rows: BusinessViewRow[], searchParams: URLSear
|
||||
.filter((row) => !decision || stringValue(row.decision) === decision);
|
||||
}
|
||||
|
||||
function artifactAccessRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return runtimeEvents(snapshot)
|
||||
.filter((event) => ['artifact_access_allowed', 'artifact_access_rejected'].includes(event.kind))
|
||||
.map((event) => {
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
return {
|
||||
access_id: event.id,
|
||||
event_id: event.id,
|
||||
remote_id: event.remoteId ?? null,
|
||||
artifact_id: stringValue(payload.artifact_id) || null,
|
||||
action: stringValue(payload.action) || null,
|
||||
status: stringValue(payload.status) || event.kind.replace('artifact_access_', ''),
|
||||
reason_codes: Array.isArray(payload.reason_codes) ? payload.reason_codes : [],
|
||||
actor: stringValue(payload.actor) || null,
|
||||
source: stringValue(payload.source) || null,
|
||||
plan_id: (event.planId ?? stringValue(payload.plan_id)) || null,
|
||||
task_id: (event.taskId ?? stringValue(payload.task_id)) || null,
|
||||
run_id: (event.runId ?? stringValue(payload.run_id)) || null,
|
||||
message: event.message,
|
||||
occurred_at: event.occurredAt,
|
||||
sync_status: event.syncStatus ?? null,
|
||||
payload,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
|
||||
}
|
||||
|
||||
function filterArtifactAccessRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
|
||||
const artifactId = stringValue(searchParams.get('artifact_id'));
|
||||
const action = stringValue(searchParams.get('action'));
|
||||
const status = stringValue(searchParams.get('status'));
|
||||
return rows
|
||||
.filter((row) => !artifactId || stringValue(row.artifact_id) === artifactId)
|
||||
.filter((row) => !action || stringValue(row.action) === action)
|
||||
.filter((row) => !status || stringValue(row.status) === status);
|
||||
}
|
||||
|
||||
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return buildArtifacts(snapshot)
|
||||
.filter(isDailyReportArtifact)
|
||||
@@ -2981,7 +3077,26 @@ function buildArtifacts(snapshot: QimingclawSnapshot | null): ArtifactEntry[] {
|
||||
runId: event.runId,
|
||||
})),
|
||||
];
|
||||
return dedupeArtifacts(artifacts);
|
||||
return dedupeArtifacts(artifacts).map((artifact) => withArtifactAccessSummary(artifact, snapshot));
|
||||
}
|
||||
|
||||
function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: QimingclawSnapshot | null): ArtifactEntry {
|
||||
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
|
||||
const lastAccess = artifactAccessRows(snapshot).find((row) => stringValue(row.artifact_id) === artifactId) ?? null;
|
||||
const hasLocalUri = isLocalArtifactUri(stringValue(artifact.uri));
|
||||
const hasDailyText = isDailyReportArtifact(artifact) && Boolean(stringValue(asRecord(artifact.payload)?.text_content));
|
||||
const formalRuntimeArtifact = runtimeArtifacts(snapshot).some((runtime) => runtime.id === artifactId || runtime.remoteId === artifactId);
|
||||
const previewable = formalRuntimeArtifact && (hasDailyText || hasLocalUri);
|
||||
return {
|
||||
...artifact,
|
||||
access: {
|
||||
previewable,
|
||||
downloadable: formalRuntimeArtifact && hasLocalUri,
|
||||
openable: formalRuntimeArtifact && hasLocalUri,
|
||||
access_policy: formalRuntimeArtifact ? 'qimingclaw-local-artifact-policy' : 'runtime-projection-readonly',
|
||||
last_access_status: stringValue(lastAccess?.status) || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function artifactFromRuntimeRecord(artifact: QimingclawArtifactRecord): ArtifactEntry {
|
||||
@@ -3181,6 +3296,13 @@ function looksLikeUri(value: string): boolean {
|
||||
return /^(file|https?):\/\//i.test(value) || value.includes('/') || value.includes('\\');
|
||||
}
|
||||
|
||||
function isLocalArtifactUri(value: string): boolean {
|
||||
if (!value) return false;
|
||||
if (/^file:\/\//i.test(value)) return true;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
|
||||
return value.startsWith('/') || /^[a-z]:[\\/]/i.test(value);
|
||||
}
|
||||
|
||||
function artifactName(value: string): string {
|
||||
if (!value) return '';
|
||||
const normalized = value.split(/[?#]/)[0] || value;
|
||||
@@ -3620,6 +3742,7 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
|
||||
task_id: stringValue(artifact.task_id) || null,
|
||||
run_id: stringValue(artifact.run_id) || null,
|
||||
created_at: stringValue(artifact.created_at) || null,
|
||||
access: artifact.access,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Eye, FolderOpen } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import {
|
||||
getDigitalEmployeeWorkday,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
accessArtifact,
|
||||
} from '@/lib/api';
|
||||
import type {
|
||||
DigitalEmployeeDailyReport,
|
||||
@@ -181,20 +182,70 @@ function DetailList({ details }: { details: DigitalEmployeeRunDetail[] }) {
|
||||
}
|
||||
|
||||
function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArtifactSource[] }) {
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<{ title: string; text: string } | null>(null);
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>运行产物出现后,这里会展示文件、输出和来源记录。</p>;
|
||||
}
|
||||
|
||||
const runAccess = async (artifact: DigitalEmployeeReportArtifactSource, action: 'preview' | 'download' | 'open_location') => {
|
||||
if (!artifact.id || busyKey) return;
|
||||
const key = `${artifact.id}:${action}`;
|
||||
setBusyKey(key);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await accessArtifact(artifact.id, action);
|
||||
if (!result?.ok) {
|
||||
setNotice(result?.error || '产物访问被拒绝');
|
||||
return;
|
||||
}
|
||||
if (action === 'preview') {
|
||||
const data = result.preview || {};
|
||||
setPreview({
|
||||
title: String(data.name || artifact.name || '产物预览'),
|
||||
text: String(data.text || `${data.mime || artifact.kind || 'artifact'} · ${data.size ?? 'unknown'}`),
|
||||
});
|
||||
} else {
|
||||
setNotice(action === 'download' ? '已打开产物' : '已打开位置');
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : '产物访问失败');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="de-artifact-source-list" aria-label="日报产物来源">
|
||||
{artifacts.map((artifact) => (
|
||||
<div key={artifact.id} className="de-artifact-source-row">
|
||||
<strong>{artifact.name}</strong>
|
||||
<span>{artifact.source_label || artifact.source_type || '未知来源'}</span>
|
||||
{artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>}
|
||||
<>
|
||||
<div className="de-artifact-source-list" aria-label="日报产物来源">
|
||||
{artifacts.map((artifact) => {
|
||||
const previewable = Boolean(artifact.access?.previewable);
|
||||
const downloadable = Boolean(artifact.access?.downloadable);
|
||||
const openable = Boolean(artifact.access?.openable);
|
||||
return (
|
||||
<div key={artifact.id} className="de-artifact-source-row">
|
||||
<strong>{artifact.name}</strong>
|
||||
<span>{artifact.source_label || artifact.source_type || '未知来源'}</span>
|
||||
<div className="de-artifact-source-actions">
|
||||
<button type="button" className="de-icon-btn" disabled={!previewable || busyKey !== null} onClick={() => runAccess(artifact, 'preview')} title="预览" aria-label="预览"><Eye size={15} /></button>
|
||||
<button type="button" className="de-icon-btn" disabled={!downloadable || busyKey !== null} onClick={() => runAccess(artifact, 'download')} title="下载" aria-label="下载"><Download size={15} /></button>
|
||||
<button type="button" className="de-icon-btn" disabled={!openable || busyKey !== null} onClick={() => runAccess(artifact, 'open_location')} title="位置" aria-label="位置"><FolderOpen size={15} /></button>
|
||||
</div>
|
||||
{artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{preview ? (
|
||||
<div className="de-artifact-preview">
|
||||
<div><strong>{preview.title}</strong><button type="button" className="de-btn-secondary" onClick={() => setPreview(null)}>关闭</button></div>
|
||||
<pre>{preview.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -475,6 +475,13 @@ export interface ArtifactEntry {
|
||||
name?: string;
|
||||
uri?: string;
|
||||
value?: string;
|
||||
access?: {
|
||||
previewable?: boolean;
|
||||
downloadable?: boolean;
|
||||
openable?: boolean;
|
||||
access_policy?: string | null;
|
||||
last_access_status?: string | null;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -758,6 +765,7 @@ export interface DigitalEmployeeReportArtifactSource {
|
||||
task_id?: string | null;
|
||||
run_id?: string | null;
|
||||
created_at?: string | null;
|
||||
access?: ArtifactEntry['access'];
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDelivery {
|
||||
|
||||
Reference in New Issue
Block a user