吸收数字员工产物访问治理

This commit is contained in:
baiyanyun
2026-06-10 10:00:34 +08:00
parent cc36d8dcaf
commit 362f397e37
18 changed files with 1016 additions and 166 deletions

View File

@@ -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;

View File

@@ -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 ?? []);

View File

@@ -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,
}));
}

View File

@@ -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}
</>
);
}

View File

@@ -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 {