Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
5abfbb86
Commit
5abfbb86
authored
May 15, 2026
by
edy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(desktop): archive workspace task panel artifacts
parent
4415dece
Pipeline
#18464
failed
Changes
12
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
796 additions
and
35 deletions
+796
-35
index.ts
apps/desktop/src/main/index.ts
+3
-3
ipc.ts
apps/desktop/src/main/ipc.ts
+99
-4
project-workspace-executor.ts
apps/desktop/src/main/services/project-workspace-executor.ts
+194
-19
task-panel-service.ts
apps/desktop/src/main/services/task-panel-service.ts
+172
-0
projectWorkspaceExecutorArtifacts.test.ts
apps/desktop/test/projectWorkspaceExecutorArtifacts.test.ts
+88
-0
taskPanelIpcSource.test.ts
apps/desktop/test/taskPanelIpcSource.test.ts
+26
-0
taskPanelService.test.ts
apps/desktop/test/taskPanelService.test.ts
+109
-0
TaskPanelView.tsx
apps/ui/src/features/tasks/TaskPanelView.tsx
+17
-9
taskPanelData.ts
apps/ui/src/features/tasks/taskPanelData.ts
+3
-0
taskPanelData.test.ts
apps/ui/test/taskPanelData.test.ts
+73
-0
taskPanelViewSource.test.ts
apps/ui/test/taskPanelViewSource.test.ts
+10
-0
index.ts
packages/shared-types/src/index.ts
+2
-0
No files found.
apps/desktop/src/main/index.ts
View file @
5abfbb86
...
...
@@ -35,6 +35,7 @@ import { ProjectExecutionRouter } from "./services/project-execution-router.js";
import
{
ProjectIntentRouterService
}
from
"./services/project-intent-router.js"
;
import
{
ProjectSkillRouterService
}
from
"./services/project-skill-router.js"
;
import
{
ProjectWorkspaceExecutorService
}
from
"./services/project-workspace-executor.js"
;
import
{
TaskPanelService
}
from
"./services/task-panel-service.js"
;
import
{
StartupLogger
}
from
"./services/startup-logger.js"
;
interface
RendererSmokeState
{
...
...
@@ -2301,6 +2302,7 @@ async function bootstrap(): Promise<void> {
await runtimeManager.configure();
await traceBootstrap("runtime-configure-done");
const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager);
const taskPanelService = new TaskPanelService(systemSummary.userDataPath);
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
...
...
@@ -2388,6 +2390,7 @@ async function bootstrap(): Promise<void> {
projectSkillRouter,
projectExecutionRouter,
projectWorkspaceExecutor,
taskPanelService,
startupLogger: startupLogger!,
systemSummary,
localOpenClawConfig
...
...
@@ -2507,6 +2510,3 @@ if (!hasSingleInstanceLock) {
}
apps/desktop/src/main/ipc.ts
View file @
5abfbb86
...
...
@@ -14,6 +14,7 @@ import {
type
PluginSummary
,
type
ProjectIntentSuggestion
,
type
ProjectResolvedAttachment
,
type
TaskPanelArtifact
,
type
RuntimeCloudFetchAction
,
type
RuntimeCloudStatus
,
type
RuntimeStatus
,
...
...
@@ -57,6 +58,7 @@ import type { ProjectContextService } from "./services/project-context.js";
import
type
{
ProjectExecutionRouter
}
from
"./services/project-execution-router.js"
;
import
type
{
ProjectSkillRouterService
}
from
"./services/project-skill-router.js"
;
import
type
{
ProjectWorkspaceExecutorService
}
from
"./services/project-workspace-executor.js"
;
import
type
{
TaskPanelService
}
from
"./services/task-panel-service.js"
;
import
{
buildProjectModelRuntime
,
materializeProjectModelRuntime
...
...
@@ -101,6 +103,7 @@ interface MainServices {
projectSkillRouter
:
ProjectSkillRouterService
;
projectExecutionRouter
:
ProjectExecutionRouter
;
projectWorkspaceExecutor
:
ProjectWorkspaceExecutorService
;
taskPanelService
:
TaskPanelService
;
startupLogger
:
StartupLogger
;
appVersion
:
string
;
systemSummary
:
SystemSummary
;
...
...
@@ -521,6 +524,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectSkillRouter,
projectExecutionRouter,
projectWorkspaceExecutor,
taskPanelService,
startupLogger,
systemSummary,
localOpenClawConfig
...
...
@@ -1646,6 +1650,65 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
};
};
const isXhsProject = (value: string): boolean => /xiaohongshu|xhs|小红书/.test(value);
const isDouyinProject = (value: string): boolean => /douyin|抖音|tiktok/.test(value);
const resolveTaskPanelExpertName = (projectId: string, projectRoot: string): string | null => {
const normalizedProjectId = projectId.trim().toLowerCase();
const projectBaseName = path.basename(projectRoot).toLowerCase();
if (isXhsProject(normalizedProjectId)) {
return "
小红书专家
";
}
if (isDouyinProject(normalizedProjectId)) {
return "
抖音专家
";
}
if (isXhsProject(projectBaseName)) {
return "
小红书专家
";
}
if (isDouyinProject(projectBaseName)) {
return "
抖音专家
";
}
return null;
};
const toTaskPanelTitle = (prompt: string): string => {
const firstLine = prompt.split(/
\
r?
\n
/).map((line) => line.trim()).find(Boolean) ?? "
未命名任务
";
return firstLine.length > 40 ? firstLine.slice(0, 40) + "
...
" : firstLine;
};
const recordWorkspaceTaskPanelExecution = async (input: {
sessionId: string;
projectId: string;
projectRoot: string;
prompt: string;
runId: string;
artifacts?: TaskPanelArtifact[];
}): Promise<void> => {
const expertName = resolveTaskPanelExpertName(input.projectId, input.projectRoot);
if (!expertName) {
return;
}
try {
await taskPanelService.recordWorkspaceExecution({
sessionId: input.sessionId,
runId: input.runId,
expertName,
taskTitle: toTaskPanelTitle(input.prompt),
completedAt: new Date().toISOString(),
messageCount: 2,
artifacts: input.artifacts ?? []
});
} catch (error) {
await startupLogger.warn("
diagnostics
", "
task
-
panel
.
archive
-
failed
", "
Failed
to
archive
workspace
-
entry
task
panel
data
.
", {
sessionId: input.sessionId,
projectId: input.projectId,
runId: input.runId,
error: error instanceof Error ? error.message : String(error)
});
}
};
const sendHomeImagePrompt = async (
sessionId: string,
prompt: string,
...
...
@@ -1865,11 +1928,27 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
fallbackExecutionPolicy.modelId,
executionSkillId
);
await recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
return { ...fallbackResult, executionPolicy: fallbackExecutionPolicy };
}
await projectStore.appendSessionMessage(executionSessionId, result.reply);
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId);
await recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
return {
sessionId: executionSessionId,
reply: result.reply,
...
...
@@ -2277,9 +2356,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
statusLabel: undefined,
statusDetail: undefined
}));
await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined);
await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId);
void recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
queueOrSend({
type: "
completed
",
requestId,
...
...
@@ -2332,6 +2419,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}));
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, executionPolicy?.modelId, executionSkillId);
await recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
queueOrSend({
type: "
completed
",
requestId,
...
...
@@ -2602,7 +2697,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.expertsList, async () => expertCatalogService.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.tasksListByDate, async (
) => []
);
ipcMain.handle(IPC_CHANNELS.tasksListByDate, async (
_event, date: string) => taskPanelService.listByDate(date)
);
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects());
...
...
@@ -2734,7 +2829,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
getSummary: () => Promise.resolve(systemSummary)
},
tasks: {
listByDate:
async () => []
listByDate:
(date: string) => taskPanelService.listByDate(date)
},
chat: {
listSessions: async () => {
...
...
apps/desktop/src/main/services/project-workspace-executor.ts
View file @
5abfbb86
import
{
randomUUID
}
from
"node:crypto"
;
import
{
spawn
}
from
"node:child_process"
;
import
{
existsSync
}
from
"node:fs"
;
import
{
readFile
,
stat
}
from
"node:fs/promises"
;
import
type
{
Dirent
}
from
"node:fs"
;
import
{
readdir
,
readFile
,
stat
}
from
"node:fs/promises"
;
import
path
from
"node:path"
;
import
type
{
RuntimeManager
}
from
"@qjclaw/runtime-manager"
;
import
type
{
ChatMessage
,
ProjectResolvedAttachment
}
from
"@qjclaw/shared-types"
;
import
type
{
ChatMessage
,
ProjectResolvedAttachment
,
TaskPanelArtifact
}
from
"@qjclaw/shared-types"
;
interface
ProjectWorkspaceExecutionInput
{
sessionId
:
string
;
...
...
@@ -39,6 +40,7 @@ interface RunnerEvent {
export
interface
ProjectWorkspaceHandoffResult
{
runId
:
string
;
artifacts
?:
TaskPanelArtifact
[];
handoff
:
{
target
:
"chat-fallback"
;
content
:
string
;
...
...
@@ -47,6 +49,7 @@ export interface ProjectWorkspaceHandoffResult {
export
interface
ProjectWorkspaceReplyResult
{
runId
:
string
;
artifacts
?:
TaskPanelArtifact
[];
reply
:
ChatMessage
;
}
...
...
@@ -68,6 +71,37 @@ interface ResolvedProjectAutomationCommand {
}
const
EVENT_PREFIX
=
"QJC_WORKSPACE_EVENT
\
t"
;
const
WORKSPACE_ARTIFACT_EXTENSIONS
=
new
Set
([
".md"
,
".txt"
,
".pdf"
,
".docx"
,
".xlsx"
,
".csv"
,
".json"
,
".mp4"
,
".mov"
,
".png"
,
".jpg"
,
".jpeg"
,
".webp"
]);
const
TEXT_SUMMARY_EXTENSIONS
=
new
Set
([
".md"
,
".txt"
]);
export
interface
WorkspaceArtifactSnapshotEntry
{
path
:
string
;
relativePath
:
string
;
size
:
number
;
mtimeMs
:
number
;
}
export
type
WorkspaceArtifactSnapshot
=
Map
<
string
,
WorkspaceArtifactSnapshotEntry
>
;
export
interface
CollectWorkspaceExecutionArtifactsInput
{
projectRoot
:
string
;
beforeSnapshot
:
WorkspaceArtifactSnapshot
;
assistantSummary
?:
string
;
}
function
toErrorMessage
(
error
:
unknown
):
string
{
if
(
error
instanceof
Error
)
{
...
...
@@ -135,6 +169,135 @@ async function pathExists(targetPath: string): Promise<boolean> {
}
}
function
toLocalDateTimeValue
(
date
:
Date
):
string
{
const
year
=
date
.
getFullYear
();
const
month
=
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
"0"
);
const
day
=
String
(
date
.
getDate
()).
padStart
(
2
,
"0"
);
const
hour
=
String
(
date
.
getHours
()).
padStart
(
2
,
"0"
);
const
minute
=
String
(
date
.
getMinutes
()).
padStart
(
2
,
"0"
);
const
second
=
String
(
date
.
getSeconds
()).
padStart
(
2
,
"0"
);
return
`
${
year
}
-
${
month
}
-
${
day
}
${
hour
}
:
${
minute
}
:
${
second
}
`
;
}
function
normalizeSnapshotPath
(
value
:
string
):
string
{
return
value
.
replace
(
/
\\
/g
,
"/"
);
}
function
shouldSkipWorkspaceArtifactPath
(
relativePath
:
string
):
boolean
{
const
segments
=
normalizeSnapshotPath
(
relativePath
).
split
(
"/"
).
filter
(
Boolean
);
if
(
segments
.
some
((
segment
)
=>
segment
===
".git"
||
segment
===
"node_modules"
||
segment
.
startsWith
(
".qjc-"
)))
{
return
true
;
}
if
(
segments
[
0
]
===
"memory"
&&
segments
[
1
]
===
"workspace-runner"
)
{
return
true
;
}
if
(
segments
[
0
]
===
"inputs"
)
{
return
true
;
}
return
false
;
}
function
inferWorkspaceArtifactKind
(
filePath
:
string
):
string
{
const
extension
=
path
.
extname
(
filePath
).
toLowerCase
();
if
(
extension
===
".mp4"
||
extension
===
".mov"
)
{
return
"视频"
;
}
if
(
extension
===
".png"
||
extension
===
".jpg"
||
extension
===
".jpeg"
||
extension
===
".webp"
)
{
return
"图片"
;
}
if
(
extension
===
".xlsx"
||
extension
===
".csv"
)
{
return
"表格"
;
}
return
"文档"
;
}
async
function
readWorkspaceArtifactSummary
(
filePath
:
string
,
fallbackSummary
?:
string
):
Promise
<
string
|
undefined
>
{
if
(
!
TEXT_SUMMARY_EXTENSIONS
.
has
(
path
.
extname
(
filePath
).
toLowerCase
()))
{
return
fallbackSummary
?.
trim
().
slice
(
0
,
120
)
||
undefined
;
}
try
{
const
content
=
await
readFile
(
filePath
,
"utf8"
);
return
content
.
replace
(
/
\s
+/g
,
" "
).
trim
().
slice
(
0
,
120
)
||
fallbackSummary
?.
trim
().
slice
(
0
,
120
)
||
undefined
;
}
catch
{
return
fallbackSummary
?.
trim
().
slice
(
0
,
120
)
||
undefined
;
}
}
async
function
scanWorkspaceArtifacts
(
projectRoot
:
string
,
currentDirectory
:
string
,
snapshot
:
WorkspaceArtifactSnapshot
):
Promise
<
void
>
{
let
entries
:
Dirent
[];
try
{
entries
=
await
readdir
(
currentDirectory
,
{
withFileTypes
:
true
});
}
catch
{
return
;
}
for
(
const
entry
of
entries
)
{
const
absolutePath
=
path
.
join
(
currentDirectory
,
entry
.
name
);
const
relativePath
=
normalizeSnapshotPath
(
path
.
relative
(
projectRoot
,
absolutePath
));
if
(
!
relativePath
||
shouldSkipWorkspaceArtifactPath
(
relativePath
))
{
continue
;
}
if
(
entry
.
isDirectory
())
{
await
scanWorkspaceArtifacts
(
projectRoot
,
absolutePath
,
snapshot
);
continue
;
}
if
(
!
entry
.
isFile
()
||
!
WORKSPACE_ARTIFACT_EXTENSIONS
.
has
(
path
.
extname
(
entry
.
name
).
toLowerCase
()))
{
continue
;
}
try
{
const
stats
=
await
stat
(
absolutePath
);
snapshot
.
set
(
relativePath
,
{
path
:
absolutePath
,
relativePath
,
size
:
stats
.
size
,
mtimeMs
:
stats
.
mtimeMs
});
}
catch
{
// Files may be moved by the runtime while we are scanning.
}
}
}
export
async
function
createWorkspaceArtifactSnapshot
(
projectRoot
:
string
):
Promise
<
WorkspaceArtifactSnapshot
>
{
const
snapshot
:
WorkspaceArtifactSnapshot
=
new
Map
();
await
scanWorkspaceArtifacts
(
projectRoot
,
projectRoot
,
snapshot
);
return
snapshot
;
}
export
async
function
collectWorkspaceExecutionArtifacts
(
input
:
CollectWorkspaceExecutionArtifactsInput
):
Promise
<
TaskPanelArtifact
[]
>
{
const
afterSnapshot
=
await
createWorkspaceArtifactSnapshot
(
input
.
projectRoot
);
const
changedEntries
=
[...
afterSnapshot
.
values
()]
.
filter
((
entry
)
=>
{
const
before
=
input
.
beforeSnapshot
.
get
(
entry
.
relativePath
);
return
!
before
||
before
.
size
!==
entry
.
size
||
before
.
mtimeMs
!==
entry
.
mtimeMs
;
})
.
sort
((
left
,
right
)
=>
left
.
relativePath
.
localeCompare
(
right
.
relativePath
));
const
artifacts
:
TaskPanelArtifact
[]
=
[];
for
(
const
entry
of
changedEntries
)
{
artifacts
.
push
({
id
:
"artifact-"
+
Buffer
.
from
(
entry
.
relativePath
).
toString
(
"base64url"
).
slice
(
0
,
32
),
name
:
path
.
basename
(
entry
.
path
),
kind
:
inferWorkspaceArtifactKind
(
entry
.
path
),
summary
:
await
readWorkspaceArtifactSummary
(
entry
.
path
,
input
.
assistantSummary
),
url
:
entry
.
path
,
path
:
entry
.
path
,
producedAt
:
toLocalDateTimeValue
(
new
Date
(
entry
.
mtimeMs
))
});
}
return
artifacts
;
}
function
parseRunnerEvent
(
line
:
string
):
RunnerEvent
|
null
{
if
(
!
line
.
startsWith
(
EVENT_PREFIX
))
{
return
null
;
...
...
@@ -299,6 +462,7 @@ export class ProjectWorkspaceExecutorService {
const
paths
=
this
.
runtimeManager
.
resolveBundledPaths
();
const
automationCommand
=
await
resolveProjectAutomationCommand
(
input
.
projectRoot
,
input
,
paths
.
pythonExecutable
);
const
runnerScriptPath
=
automationCommand
?
null
:
await
resolveRunnerScriptPath
();
const
beforeArtifactSnapshot
=
await
createWorkspaceArtifactSnapshot
(
input
.
projectRoot
);
const
vendorPackageDir
=
path
.
join
(
paths
.
runtimeDir
,
"openclaw"
,
"package"
);
const
instrumentationDir
=
path
.
join
(
paths
.
runtimeDataDir
,
"workspace-runner"
,
"instrumented-modules"
);
...
...
@@ -360,6 +524,11 @@ export class ProjectWorkspaceExecutorService {
settled
=
true
;
reject
(
buildWorkspaceExecutionError
(
message
,
errorCategory
));
};
const
collectArtifacts
=
(
assistantSummary
?:
string
)
=>
collectWorkspaceExecutionArtifacts
({
projectRoot
:
input
.
projectRoot
,
beforeSnapshot
:
beforeArtifactSnapshot
,
assistantSummary
}).
catch
(()
=>
[]);
child
.
stdout
.
setEncoding
(
"utf8"
);
child
.
stdout
.
on
(
"data"
,
(
chunk
:
string
)
=>
{
...
...
@@ -401,14 +570,17 @@ export class ProjectWorkspaceExecutorService {
const
content
=
typeof
event
.
content
===
"string"
&&
event
.
content
.
trim
()
?
event
.
content
:
extractReplyText
(
event
.
result
);
resolve
({
runId
:
activeRunId
,
reply
:
{
id
:
randomUUID
(),
role
:
"assistant"
,
content
,
createdAt
:
new
Date
().
toISOString
()
}
void
collectArtifacts
(
content
).
then
((
artifacts
)
=>
{
resolve
({
runId
:
activeRunId
,
artifacts
,
reply
:
{
id
:
randomUUID
(),
role
:
"assistant"
,
content
,
createdAt
:
new
Date
().
toISOString
()
}
});
});
return
;
}
...
...
@@ -418,14 +590,18 @@ export class ProjectWorkspaceExecutorService {
return
;
}
settled
=
true
;
resolve
({
runId
:
activeRunId
,
handoff
:
{
target
:
"chat-fallback"
,
content
:
typeof
event
.
content
===
"string"
&&
event
.
content
.
trim
()
?
event
.
content
:
input
.
userPrompt
?.
trim
()
||
input
.
prompt
}
const
content
=
typeof
event
.
content
===
"string"
&&
event
.
content
.
trim
()
?
event
.
content
:
input
.
userPrompt
?.
trim
()
||
input
.
prompt
;
void
collectArtifacts
(
content
).
then
((
artifacts
)
=>
{
resolve
({
runId
:
activeRunId
,
artifacts
,
handoff
:
{
target
:
"chat-fallback"
,
content
}
});
});
return
;
}
...
...
@@ -473,4 +649,3 @@ export class ProjectWorkspaceExecutorService {
});
}
}
apps/desktop/src/main/services/task-panel-service.ts
0 → 100644
View file @
5abfbb86
import
{
createHash
,
randomUUID
}
from
"node:crypto"
;
import
{
mkdir
,
readFile
,
rename
,
writeFile
}
from
"node:fs/promises"
;
import
path
from
"node:path"
;
import
type
{
TaskPanelArtifact
,
TaskPanelItem
}
from
"@qjclaw/shared-types"
;
interface
TaskPanelState
{
itemsByDate
:
Record
<
string
,
TaskPanelItem
[]
>
;
}
export
interface
RecordWorkspaceExecutionInput
{
sessionId
:
string
;
runId
:
string
;
date
?:
string
;
expertName
:
string
;
taskTitle
:
string
;
completedAt
?:
string
;
messageCount
?:
number
;
artifacts
:
TaskPanelArtifact
[];
}
const
VALID_DATE_PATTERN
=
/^
\d{4}
-
\d{2}
-
\d{2}
$/
;
function
createEmptyState
():
TaskPanelState
{
return
{
itemsByDate
:
{}
};
}
function
hashValue
(
value
:
string
):
string
{
return
createHash
(
"sha256"
).
update
(
value
).
digest
(
"hex"
).
slice
(
0
,
16
);
}
function
normalizeArtifactKey
(
artifact
:
TaskPanelArtifact
):
string
{
const
key
=
artifact
.
path
?.
trim
()
||
artifact
.
url
?.
trim
()
||
artifact
.
name
.
trim
()
||
artifact
.
id
;
return
key
.
replace
(
/
[\\/]
+/g
,
"/"
).
toLowerCase
();
}
function
dedupeArtifacts
(
artifacts
:
TaskPanelArtifact
[]):
TaskPanelArtifact
[]
{
const
seen
=
new
Set
<
string
>
();
const
deduped
:
TaskPanelArtifact
[]
=
[];
for
(
const
artifact
of
artifacts
)
{
const
key
=
normalizeArtifactKey
(
artifact
);
if
(
!
key
||
seen
.
has
(
key
))
{
continue
;
}
seen
.
add
(
key
);
deduped
.
push
({
...
artifact
,
id
:
artifact
.
id
||
"artifact-"
+
hashValue
(
key
),
url
:
artifact
.
url
??
artifact
.
path
});
}
return
deduped
;
}
function
toLocalDateInputValue
(
date
:
Date
):
string
{
const
year
=
date
.
getFullYear
();
const
month
=
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
"0"
);
const
day
=
String
(
date
.
getDate
()).
padStart
(
2
,
"0"
);
return
`
${
year
}
-
${
month
}
-
${
day
}
`
;
}
function
toLocalTimeValue
(
date
:
Date
):
string
{
const
hour
=
String
(
date
.
getHours
()).
padStart
(
2
,
"0"
);
const
minute
=
String
(
date
.
getMinutes
()).
padStart
(
2
,
"0"
);
const
second
=
String
(
date
.
getSeconds
()).
padStart
(
2
,
"0"
);
return
`
${
hour
}
:
${
minute
}
:
${
second
}
`
;
}
function
parseCompletedAt
(
value
?:
string
):
Date
{
const
date
=
value
?
new
Date
(
value
)
:
new
Date
();
return
Number
.
isNaN
(
date
.
getTime
())
?
new
Date
()
:
date
;
}
function
isTaskPanelState
(
value
:
unknown
):
value
is
TaskPanelState
{
return
Boolean
(
value
&&
typeof
value
===
"object"
&&
!
Array
.
isArray
(
value
)
&&
typeof
(
value
as
{
itemsByDate
?:
unknown
}).
itemsByDate
===
"object"
&&
!
Array
.
isArray
((
value
as
{
itemsByDate
?:
unknown
}).
itemsByDate
)
);
}
export
class
TaskPanelService
{
private
readonly
statePath
:
string
;
private
writeChain
:
Promise
<
unknown
>
=
Promise
.
resolve
();
constructor
(
userDataPath
:
string
)
{
this
.
statePath
=
path
.
join
(
userDataPath
,
"task-panel"
,
"state.json"
);
}
async
listByDate
(
date
:
string
):
Promise
<
TaskPanelItem
[]
>
{
if
(
!
VALID_DATE_PATTERN
.
test
(
date
))
{
return
[];
}
const
state
=
await
this
.
loadState
();
return
[...(
state
.
itemsByDate
[
date
]
??
[])];
}
async
recordWorkspaceExecution
(
input
:
RecordWorkspaceExecutionInput
):
Promise
<
TaskPanelItem
|
null
>
{
const
operation
=
this
.
writeChain
.
catch
(()
=>
undefined
)
.
then
(()
=>
this
.
recordWorkspaceExecutionUnlocked
(
input
));
this
.
writeChain
=
operation
;
return
operation
;
}
private
async
recordWorkspaceExecutionUnlocked
(
input
:
RecordWorkspaceExecutionInput
):
Promise
<
TaskPanelItem
|
null
>
{
const
completedAt
=
parseCompletedAt
(
input
.
completedAt
);
const
date
=
input
.
date
&&
VALID_DATE_PATTERN
.
test
(
input
.
date
)
?
input
.
date
:
toLocalDateInputValue
(
completedAt
);
const
runKey
=
`
${
input
.
sessionId
.
trim
()}
::
${
input
.
runId
.
trim
()
||
randomUUID
()}
`
;
const
itemId
=
"task-"
+
hashValue
(
runKey
);
const
artifacts
=
dedupeArtifacts
(
input
.
artifacts
);
const
nextItem
:
TaskPanelItem
=
{
id
:
itemId
,
date
,
expertName
:
input
.
expertName
,
taskTitle
:
input
.
taskTitle
,
status
:
"completed"
,
statusDetail
:
artifacts
.
length
?
`已完成,识别到
${
artifacts
.
length
}
个产物`
:
"已完成"
,
creditsUsed
:
0
,
messageCount
:
input
.
messageCount
??
2
,
updatedAt
:
toLocalTimeValue
(
completedAt
),
artifacts
};
const
state
=
await
this
.
loadState
();
const
items
=
[...(
state
.
itemsByDate
[
date
]
??
[])];
const
existingIndex
=
items
.
findIndex
((
item
)
=>
item
.
id
===
itemId
);
if
(
existingIndex
>=
0
)
{
const
existing
=
items
[
existingIndex
]
!
;
nextItem
.
artifacts
=
dedupeArtifacts
([...
existing
.
artifacts
,
...
nextItem
.
artifacts
]);
nextItem
.
statusDetail
=
nextItem
.
artifacts
.
length
?
`已完成,识别到
${
nextItem
.
artifacts
.
length
}
个产物`
:
"已完成"
;
items
[
existingIndex
]
=
nextItem
;
}
else
{
items
.
push
(
nextItem
);
}
state
.
itemsByDate
[
date
]
=
items
.
sort
((
left
,
right
)
=>
{
const
leftKey
=
`
${
left
.
date
}
${
left
.
updatedAt
??
""
}
`;
const rightKey = `
$
{
right
.
date
}
$
{
right
.
updatedAt
??
""
}
`;
return rightKey.localeCompare(leftKey);
});
await this.saveState(state);
return nextItem;
}
private async loadState(): Promise<TaskPanelState> {
try {
const raw = await readFile(this.statePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
return isTaskPanelState(parsed) ? parsed : createEmptyState();
} catch {
return createEmptyState();
}
}
private async saveState(state: TaskPanelState): Promise<void> {
await mkdir(path.dirname(this.statePath), { recursive: true });
const tempPath = this.statePath + ".tmp";
await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8");
await rename(tempPath, this.statePath);
}
}
apps/desktop/test/projectWorkspaceExecutorArtifacts.test.ts
0 → 100644
View file @
5abfbb86
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
mkdir
,
mkdtemp
,
rm
,
writeFile
}
from
"node:fs/promises"
import
{
tmpdir
}
from
"node:os"
import
path
from
"node:path"
import
{
collectWorkspaceExecutionArtifacts
,
createWorkspaceArtifactSnapshot
}
from
"../src/main/services/project-workspace-executor.ts"
async
function
withProjectRoot
<
T
>
(
run
:
(
projectRoot
:
string
)
=>
Promise
<
T
>
)
{
const
projectRoot
=
await
mkdtemp
(
path
.
join
(
tmpdir
(),
"qjc-workspace-artifacts-"
))
try
{
return
await
run
(
projectRoot
)
}
finally
{
await
rm
(
projectRoot
,
{
recursive
:
true
,
force
:
true
})
}
}
test
(
"detects new supported documents and summarizes markdown content"
,
async
()
=>
{
await
withProjectRoot
(
async
(
projectRoot
)
=>
{
const
before
=
await
createWorkspaceArtifactSnapshot
(
projectRoot
)
await
writeFile
(
path
.
join
(
projectRoot
,
"result.md"
),
"这是本次产物摘要内容,用于工作台展示。"
.
repeat
(
6
),
"utf8"
)
const
artifacts
=
await
collectWorkspaceExecutionArtifacts
({
projectRoot
,
beforeSnapshot
:
before
,
assistantSummary
:
"assistant fallback"
})
assert
.
equal
(
artifacts
.
length
,
1
)
assert
.
equal
(
artifacts
[
0
]?.
name
,
"result.md"
)
assert
.
equal
(
artifacts
[
0
]?.
kind
,
"文档"
)
assert
.
equal
(
artifacts
[
0
]?.
path
,
path
.
join
(
projectRoot
,
"result.md"
))
assert
.
equal
(
artifacts
[
0
]?.
url
,
path
.
join
(
projectRoot
,
"result.md"
))
assert
.
match
(
artifacts
[
0
]?.
summary
??
""
,
/^这是本次产物摘要内容/
)
assert
.
ok
(
artifacts
[
0
]?.
producedAt
)
})
})
test
(
"detects modified files and ignores unsupported or excluded paths"
,
async
()
=>
{
await
withProjectRoot
(
async
(
projectRoot
)
=>
{
await
writeFile
(
path
.
join
(
projectRoot
,
"existing.txt"
),
"before"
,
"utf8"
)
const
before
=
await
createWorkspaceArtifactSnapshot
(
projectRoot
)
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
20
))
await
writeFile
(
path
.
join
(
projectRoot
,
"existing.txt"
),
"after"
,
"utf8"
)
await
writeFile
(
path
.
join
(
projectRoot
,
"scratch.tmp"
),
"ignore"
,
"utf8"
)
await
mkdir
(
path
.
join
(
projectRoot
,
"node_modules"
),
{
recursive
:
true
})
await
writeFile
(
path
.
join
(
projectRoot
,
"node_modules"
,
"package.md"
),
"ignore"
,
"utf8"
)
await
mkdir
(
path
.
join
(
projectRoot
,
"memory"
,
"workspace-runner"
),
{
recursive
:
true
})
await
writeFile
(
path
.
join
(
projectRoot
,
"memory"
,
"workspace-runner"
,
"trace.md"
),
"ignore"
,
"utf8"
)
const
artifacts
=
await
collectWorkspaceExecutionArtifacts
({
projectRoot
,
beforeSnapshot
:
before
,
assistantSummary
:
"assistant fallback"
})
assert
.
deepEqual
(
artifacts
.
map
((
artifact
)
=>
artifact
.
name
),
[
"existing.txt"
])
assert
.
equal
(
artifacts
[
0
]?.
kind
,
"文档"
)
})
})
test
(
"infers image video and spreadsheet artifact kinds"
,
async
()
=>
{
await
withProjectRoot
(
async
(
projectRoot
)
=>
{
const
before
=
await
createWorkspaceArtifactSnapshot
(
projectRoot
)
await
writeFile
(
path
.
join
(
projectRoot
,
"cover.png"
),
"image"
,
"utf8"
)
await
writeFile
(
path
.
join
(
projectRoot
,
"clip.mp4"
),
"video"
,
"utf8"
)
await
writeFile
(
path
.
join
(
projectRoot
,
"data.xlsx"
),
"sheet"
,
"utf8"
)
const
artifacts
=
await
collectWorkspaceExecutionArtifacts
({
projectRoot
,
beforeSnapshot
:
before
,
assistantSummary
:
"assistant fallback"
})
assert
.
deepEqual
(
Object
.
fromEntries
(
artifacts
.
map
((
artifact
)
=>
[
artifact
.
name
,
artifact
.
kind
])),
{
"clip.mp4"
:
"视频"
,
"cover.png"
:
"图片"
,
"data.xlsx"
:
"表格"
}
)
})
})
apps/desktop/test/taskPanelIpcSource.test.ts
0 → 100644
View file @
5abfbb86
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
readFileSync
}
from
"node:fs"
const
ipcSource
=
readFileSync
(
new
URL
(
"../src/main/ipc.ts"
,
import
.
meta
.
url
),
"utf8"
)
const
indexSource
=
readFileSync
(
new
URL
(
"../src/main/index.ts"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"desktop IPC wires tasks:list-by-date to TaskPanelService"
,
()
=>
{
assert
.
match
(
ipcSource
,
/taskPanelService
\.
listByDate
\(
date
\)
/
)
assert
.
doesNotMatch
(
ipcSource
,
/tasksListByDate,
\s
*async
\(\)
=>
\[\]
/
)
assert
.
match
(
indexSource
,
/new TaskPanelService
\(
systemSummary
\.
userDataPath
\)
/
)
})
test
(
"workspace-entry success paths archive xhs and douyin executions without throwing"
,
()
=>
{
assert
.
match
(
ipcSource
,
/recordWorkspaceTaskPanelExecution/
)
assert
.
match
(
ipcSource
,
/xiaohongshu
\|
xhs
\|
小红书/
)
assert
.
match
(
ipcSource
,
/douyin
\|
抖音
\|
tiktok/
)
})
test
(
"task panel expert matching prioritizes project id before filesystem path"
,
()
=>
{
assert
.
match
(
ipcSource
,
/const normalizedProjectId = projectId
\.
trim
\(\)\.
toLowerCase
\(\)
/
)
assert
.
match
(
ipcSource
,
/const projectBaseName = path
\.
basename
\(
projectRoot
\)\.
toLowerCase
\(\)
/
)
assert
.
match
(
ipcSource
,
/isXhsProject
\(
normalizedProjectId
\)
/
)
assert
.
match
(
ipcSource
,
/isDouyinProject
\(
normalizedProjectId
\)
/
)
assert
.
doesNotMatch
(
ipcSource
,
/`
\$\{
projectId
\}
\$\{
path
\.
basename
\(
projectRoot
\)\}
\$\{
projectRoot
\}
`/
)
})
apps/desktop/test/taskPanelService.test.ts
0 → 100644
View file @
5abfbb86
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
mkdir
,
mkdtemp
,
rm
,
writeFile
}
from
"node:fs/promises"
import
{
tmpdir
}
from
"node:os"
import
path
from
"node:path"
import
{
TaskPanelService
}
from
"../src/main/services/task-panel-service.ts"
async
function
withService
<
T
>
(
run
:
(
service
:
TaskPanelService
,
userDataPath
:
string
)
=>
Promise
<
T
>
)
{
const
userDataPath
=
await
mkdtemp
(
path
.
join
(
tmpdir
(),
"qjc-task-panel-"
))
try
{
const
service
=
new
TaskPanelService
(
userDataPath
)
return
await
run
(
service
,
userDataPath
)
}
finally
{
await
rm
(
userDataPath
,
{
recursive
:
true
,
force
:
true
})
}
}
test
(
"returns an empty list for empty state and invalid dates"
,
async
()
=>
{
await
withService
(
async
(
service
)
=>
{
assert
.
deepEqual
(
await
service
.
listByDate
(
"2026-05-15"
),
[])
assert
.
deepEqual
(
await
service
.
listByDate
(
"2026/05/15"
),
[])
})
})
test
(
"records expert workspace executions and sorts newest first"
,
async
()
=>
{
await
withService
(
async
(
service
)
=>
{
await
service
.
recordWorkspaceExecution
({
sessionId
:
"session-xhs"
,
runId
:
"run-xhs"
,
date
:
"2026-05-15"
,
expertName
:
"小红书专家"
,
taskTitle
:
"整理小红书选题"
,
completedAt
:
"2026-05-15T02:00:00.000Z"
,
artifacts
:
[
{
id
:
"a"
,
name
:
"xhs.md"
,
kind
:
"文档"
,
path
:
"/tmp/xhs.md"
,
url
:
"/tmp/xhs.md"
,
producedAt
:
"2026-05-15 10:00:00"
}
]
})
await
service
.
recordWorkspaceExecution
({
sessionId
:
"session-douyin"
,
runId
:
"run-douyin"
,
date
:
"2026-05-15"
,
expertName
:
"抖音专家"
,
taskTitle
:
"整理抖音脚本"
,
completedAt
:
"2026-05-15T03:00:00.000Z"
,
artifacts
:
[
{
id
:
"b"
,
name
:
"douyin.csv"
,
kind
:
"表格"
,
path
:
"/tmp/douyin.csv"
,
url
:
"/tmp/douyin.csv"
,
producedAt
:
"2026-05-15 11:00:00"
}
]
})
const
items
=
await
service
.
listByDate
(
"2026-05-15"
)
assert
.
equal
(
items
.
length
,
2
)
assert
.
equal
(
items
[
0
]?.
expertName
,
"抖音专家"
)
assert
.
equal
(
items
[
1
]?.
expertName
,
"小红书专家"
)
assert
.
equal
(
items
[
0
]?.
messageCount
,
2
)
})
})
test
(
"dedupes repeated runs and repeated artifact paths"
,
async
()
=>
{
await
withService
(
async
(
service
)
=>
{
const
input
=
{
sessionId
:
"session-xhs"
,
runId
:
"run-xhs"
,
date
:
"2026-05-15"
,
expertName
:
"小红书专家"
,
taskTitle
:
"整理小红书选题"
,
completedAt
:
"2026-05-15T02:00:00.000Z"
,
artifacts
:
[
{
id
:
"one"
,
name
:
"xhs.md"
,
kind
:
"文档"
,
path
:
"/tmp/xhs.md"
,
url
:
"/tmp/xhs.md"
,
producedAt
:
"2026-05-15 10:00:00"
},
{
id
:
"two"
,
name
:
"xhs-copy.md"
,
kind
:
"文档"
,
path
:
"/tmp/xhs.md"
,
url
:
"/tmp/xhs.md"
,
producedAt
:
"2026-05-15 10:00:00"
}
]
}
await
service
.
recordWorkspaceExecution
(
input
)
await
service
.
recordWorkspaceExecution
(
input
)
const
items
=
await
service
.
listByDate
(
"2026-05-15"
)
assert
.
equal
(
items
.
length
,
1
)
assert
.
equal
(
items
[
0
]?.
artifacts
.
length
,
1
)
})
})
test
(
"preserves all tasks when multiple executions are recorded concurrently"
,
async
()
=>
{
await
withService
(
async
(
service
)
=>
{
await
Promise
.
all
(
Array
.
from
({
length
:
12
},
(
_
,
index
)
=>
service
.
recordWorkspaceExecution
({
sessionId
:
`session-
${
index
}
`
,
runId
:
`run-
${
index
}
`
,
date
:
"2026-05-15"
,
expertName
:
index
%
2
===
0
?
"小红书专家"
:
"抖音专家"
,
taskTitle
:
`并发任务
${
index
}
`
,
completedAt
:
`2026-05-15T02:00:
${
String
(
index
).
padStart
(
2
,
"0"
)}
.000Z`
,
artifacts
:
[
{
id
:
`artifact-
${
index
}
`
,
name
:
`result-
${
index
}
.md`
,
kind
:
"文档"
,
path
:
`/tmp/result-
${
index
}
.md`
,
url
:
`/tmp/result-
${
index
}
.md`
}
]
})))
const
items
=
await
service
.
listByDate
(
"2026-05-15"
)
assert
.
equal
(
items
.
length
,
12
)
assert
.
deepEqual
(
new
Set
(
items
.
map
((
item
)
=>
item
.
id
)).
size
,
12
)
})
})
test
(
"falls back to empty state when the state file is corrupt"
,
async
()
=>
{
await
withService
(
async
(
service
,
userDataPath
)
=>
{
await
mkdir
(
path
.
join
(
userDataPath
,
"task-panel"
),
{
recursive
:
true
})
await
writeFile
(
path
.
join
(
userDataPath
,
"task-panel"
,
"state.json"
),
"{bad json"
,
"utf8"
)
assert
.
deepEqual
(
await
service
.
listByDate
(
"2026-05-15"
),
[])
})
})
apps/ui/src/features/tasks/TaskPanelView.tsx
View file @
5abfbb86
...
...
@@ -85,7 +85,7 @@ function TaskPanelMetricIcon({ kind }: { kind: "credits" | "messages" | "artifac
}
function
TaskPanelOutputIcon
({
artifact
}:
{
artifact
:
TaskPanelArtifact
})
{
const
normalizedKind
=
[
artifact
.
kind
,
artifact
.
name
,
artifact
.
url
].
filter
(
Boolean
).
join
(
" "
).
toLowerCase
()
const
normalizedKind
=
[
artifact
.
kind
,
artifact
.
name
,
artifact
.
path
,
artifact
.
url
].
filter
(
Boolean
).
join
(
" "
).
toLowerCase
()
if
(
/视频|video|mp4|mov|m4v|avi|webm/
.
test
(
normalizedKind
))
{
return
(
<
svg
viewBox=
"0 0 24 24"
aria
-
hidden=
"true"
focusable=
"false"
>
...
...
@@ -134,7 +134,12 @@ function TaskPanelStatCards({ items }: { items: TaskPanelItem[] }) {
)
}
function
formatTaskPanelOutputTime
(
task
:
TaskPanelItem
)
{
function
formatTaskPanelOutputTime
(
task
:
TaskPanelItem
,
artifact
?:
TaskPanelArtifact
)
{
const
producedAtText
=
artifact
?
artifact
.
producedAt
??
""
:
""
if
(
producedAtText
.
trim
())
{
return
producedAtText
.
trim
()
}
const
dateText
=
task
.
date
.
replaceAll
(
"-"
,
"/"
)
const
timeText
=
task
.
updatedAt
?.
trim
()
??
"00:00:00"
const
timeMatch
=
timeText
.
match
(
/^
(\d{1,2})
:
(\d{2})(?:
:
(\d{2}))?
$/
)
...
...
@@ -191,7 +196,9 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
</
div
>
</
div
>
<
div
className=
"task-panel-output-list"
>
{
outputs
.
length
?
outputs
.
map
(({
artifact
,
task
})
=>
(
{
outputs
.
length
?
outputs
.
map
(({
artifact
,
task
})
=>
{
const
artifactPath
=
artifact
.
path
??
artifact
.
url
return
(
<
article
key=
{
task
.
id
+
"-"
+
artifact
.
id
}
className=
"task-panel-output-item"
>
<
span
className=
"task-panel-output-icon"
aria
-
hidden=
"true"
>
<
TaskPanelOutputIcon
artifact=
{
artifact
}
/>
...
...
@@ -202,15 +209,15 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
<
span
className=
"task-panel-output-kind"
>
{
artifact
.
kind
??
"产物"
}
</
span
>
</
div
>
<
p
>
{
artifact
.
summary
??
task
.
taskTitle
}
</
p
>
{
artifact
.
url
?
(
{
artifact
Path
?
(
<
div
className=
"task-panel-output-url-row"
>
<
button
type=
"button"
className=
"task-panel-output-url"
title=
{
artifact
.
url
}
onClick=
{
()
=>
void
copyArtifactUrl
(
artifact
.
id
,
artifact
.
url
??
""
)
}
title=
{
artifact
Path
}
onClick=
{
()
=>
void
copyArtifactUrl
(
artifact
.
id
,
artifact
Path
)
}
>
{
artifact
.
url
}
{
artifact
Path
}
</
button
>
{
copiedArtifactId
===
artifact
.
id
?
(
<
span
className=
"task-panel-artifact-copied"
aria
-
live=
"polite"
>
✅已复制
</
span
>
...
...
@@ -224,11 +231,12 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
</
span
>
<
div
>
<
strong
title=
{
task
.
expertName
}
>
{
task
.
expertName
}
</
strong
>
<
span
title=
{
task
.
taskTitle
}
>
{
formatTaskPanelOutputTime
(
task
)
}
·
{
task
.
taskTitle
}
</
span
>
<
span
title=
{
task
.
taskTitle
}
>
{
formatTaskPanelOutputTime
(
task
,
artifact
)
}
·
{
task
.
taskTitle
}
</
span
>
</
div
>
</
div
>
</
article
>
))
:
(
)
})
:
(
<
div
className=
"empty-state task-panel-state task-panel-output-empty"
>
当前日期暂无内容产出
</
div
>
...
...
apps/ui/src/features/tasks/taskPanelData.ts
View file @
5abfbb86
...
...
@@ -146,5 +146,8 @@ export function summarizeTaskPanelItems(items: TaskPanelItem[]): TaskPanelSummar
}
export
async
function
loadTaskPanelItems
(
date
:
string
):
Promise
<
TaskPanelItem
[]
>
{
if
(
typeof
window
!==
"undefined"
&&
window
.
qjcDesktop
)
{
return
window
.
qjcDesktop
.
tasks
.
listByDate
(
date
)
}
return
mockTaskPanelItems
.
filter
((
item
)
=>
item
.
date
===
date
)
}
apps/ui/test/taskPanelData.test.ts
0 → 100644
View file @
5abfbb86
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
test
(
"loads real task panel data from the desktop bridge when available"
,
async
()
=>
{
const
calls
:
string
[]
=
[]
globalThis
.
window
=
{
qjcDesktop
:
{
tasks
:
{
listByDate
:
async
(
date
:
string
)
=>
{
calls
.
push
(
date
)
return
[
{
id
:
"real-task"
,
date
,
expertName
:
"小红书专家"
,
taskTitle
:
"真实任务"
,
status
:
"completed"
,
statusDetail
:
"已完成"
,
messageCount
:
2
,
updatedAt
:
"10:00:00"
,
artifacts
:
[]
}
]
}
}
}
}
as
unknown
as
Window
&
typeof
globalThis
const
{
loadTaskPanelItems
}
=
await
import
(
"../src/features/tasks/taskPanelData.ts"
)
const
items
=
await
loadTaskPanelItems
(
"2026-05-15"
)
assert
.
deepEqual
(
calls
,
[
"2026-05-15"
])
assert
.
equal
(
items
[
0
]?.
expertName
,
"小红书专家"
)
})
test
(
"summarizes task panel items from real artifacts and messages"
,
async
()
=>
{
const
{
summarizeTaskPanelItems
}
=
await
import
(
"../src/features/tasks/taskPanelData.ts"
)
assert
.
deepEqual
(
summarizeTaskPanelItems
([
{
id
:
"one"
,
date
:
"2026-05-15"
,
expertName
:
"小红书专家"
,
taskTitle
:
"one"
,
status
:
"completed"
,
statusDetail
:
"已完成"
,
messageCount
:
2
,
updatedAt
:
"10:00:00"
,
artifacts
:
[
{
id
:
"a"
,
name
:
"a.md"
,
path
:
"/tmp/a.md"
},
{
id
:
"b"
,
name
:
"b.png"
,
path
:
"/tmp/b.png"
}
]
},
{
id
:
"two"
,
date
:
"2026-05-15"
,
expertName
:
"抖音专家"
,
taskTitle
:
"two"
,
status
:
"completed"
,
statusDetail
:
"已完成"
,
messageCount
:
2
,
updatedAt
:
"11:00:00"
,
artifacts
:
[
{
id
:
"c"
,
name
:
"c.mp4"
,
path
:
"/tmp/c.mp4"
}
]
}
]),
{
creditsUsed
:
0
,
messageCount
:
4
,
artifactCount
:
3
,
employeeCount
:
2
})
})
apps/ui/test/taskPanelViewSource.test.ts
0 → 100644
View file @
5abfbb86
import
test
from
"node:test"
import
assert
from
"node:assert/strict"
import
{
readFileSync
}
from
"node:fs"
const
source
=
readFileSync
(
new
URL
(
"../src/features/tasks/TaskPanelView.tsx"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"task panel output uses real artifact paths and produced timestamps"
,
()
=>
{
assert
.
match
(
source
,
/artifact
\.
path
\s
*
\?\?\s
*artifact
\.
url/
)
assert
.
match
(
source
,
/artifact
\.
producedAt
\s
*
\?\?
/
)
})
packages/shared-types/src/index.ts
View file @
5abfbb86
...
...
@@ -863,6 +863,8 @@ export interface TaskPanelArtifact {
kind
?:
string
;
summary
?:
string
;
url
?:
string
;
path
?:
string
;
producedAt
?:
string
;
}
export
interface
TaskPanelItem
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment