From 851aae79f232aa0c55c7348d68204c9709724c30 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 4 Oct 2026 02:31:30 +0800 Subject: [PATCH 01/16] feat(chat): add authorized ordinary workspace conversations Signed-off-by: huangruiteng --- apps/presentation/dashboard/src/data/chat.ts | 15 +- .../personal-workspace/goal-activity.ts | 2 +- .../project-conversation-panel.tsx | 134 ++++++++++++++++++ .../project-conversation.css | 26 ++++ .../workspace-settings-page.tsx | 7 +- .../dashboard/src/views/dashboard-page.tsx | 2 +- loopx/chat_agent.py | 4 +- loopx/chat_project_context.py | 60 ++++++++ loopx/chat_runtime.py | 47 +++++- loopx/chat_server.py | 28 +++- loopx/chat_session_open.py | 16 ++- loopx/chat_store.py | 17 ++- loopx/control_plane/collaboration/__init__.py | 1 + .../collaboration/conversation_scope.ts | 25 ++++ .../collaboration/peer_context.ts | 2 +- .../collaboration/project_conversation.ts | 26 ++++ .../control_plane/effect_runtime_handlers.ts | 1 + .../project_registry_io_manifest_v1.json | 8 +- .../project_conversation.test.ts | 30 ++++ tests/test_chat_ordinary_project.py | 122 ++++++++++++++++ 20 files changed, 549 insertions(+), 24 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/project-conversation-panel.tsx create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/project-conversation.css create mode 100644 loopx/chat_project_context.py create mode 100644 loopx/control_plane/collaboration/project_conversation.ts create mode 100644 tests/control_plane_ts/project_conversation.test.ts create mode 100644 tests/test_chat_ordinary_project.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d3215c14f7..b23930d402 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -674,7 +674,8 @@ export type ChatStreamEvent = { export type ChatSessionSummary = { session_id: string; - goal_id: string; + goal_id: string | null; + project_ref?: string | null; agent_id: string; adapter_kind: string; channel_id?: string; @@ -692,6 +693,18 @@ export type ChatSessionSummary = { manager_runtime?: ManagerRuntimeSessionReadback | null; }; +export type ChatProject = {project_ref: string; title: string; grant: "workspace_read"}; + +export async function fetchChatProjects(signal?: AbortSignal) { + return requestJson<{ok: true; projects: ChatProject[]}>("/api/chat/projects", {signal}); +} + +export async function createProjectChatSession(projectRef: string, mode: "new" | "resume_latest" = "resume_latest") { + return requestJson<{ok: true; session_id: string; resumed: boolean; session: ChatSessionSummary}>("/api/chat/sessions", { + method: "POST", body: JSON.stringify({context_kind: "project", project_ref: projectRef, mode}), + }); +} + /** ``chat_store`` Session modes; an omitted mode is a managed runtime Session. */ export type ChatSessionMode = "managed_runtime" | "attached_host"; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts index 5fa39006ca..777c0c143b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts @@ -8,7 +8,7 @@ export type WorkspaceGoalExecution = | { kind: "unknown" }; export type GoalSessionFact = { - goal_id: string; + goal_id: string | null; agent_id: string; active_turn_id: string | null; host_surface?: string | null; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/project-conversation-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/project-conversation-panel.tsx new file mode 100644 index 0000000000..f8169a0165 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/project-conversation-panel.tsx @@ -0,0 +1,134 @@ +import {useEffect, useRef, useState} from "react"; +import { + acceptChatTurn, closeChatSession, createProjectChatSession, fetchChatProjects, + fetchChatSession, interruptChatTurn, streamChatTurn, + type ChatProject, type ChatSessionSummary, type ChatVisibleMessage, +} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; +import {MarkdownText} from "./markdown"; +import "./project-conversation.css"; + +/** A companion of Core project Chat. Workspace choices are host observations, + * not client-authored grants, Goals or an alternative conversation store. + */ +export function ProjectConversationPanel() { + const {locale} = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const [projects, setProjects] = useState([]); + const [project, setProject] = useState(""); + const [session, setSession] = useState(null); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [turnId, setTurnId] = useState(null); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); + const attempt = useRef<{sessionId: string; text: string; requestId: string} | null>(null); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + const abort = new AbortController(); + fetchChatProjects(abort.signal).then(result => { + setProjects(result.projects); + if (result.projects.length === 1) setProject(result.projects[0].project_ref); + }).catch(error => {if (!abort.signal.aborted) setError(String(error));}); + return () => {mounted.current = false; abort.abort();}; + }, []); + + async function refresh(sessionId: string) { + const current = await fetchChatSession(sessionId); + if (!mounted.current) return; + setSession(current.session); + setMessages(current.messages); + setTurnId(current.session.active_turn_id); + } + + async function open(mode: "new" | "resume_latest") { + const opened = await createProjectChatSession(project, mode); + if (mounted.current) { + attempt.current = null; + await refresh(opened.session_id); + } + return opened.session_id; + } + + async function send() { + if (!draft.trim() || !project || busy) return; + const text = draft; + setBusy(true); setError(""); + setStatus(zh ? "正在连接执行器" : "Connecting to the executor"); + try { + const id = session?.session_id ?? await open("resume_latest"); + if (!attempt.current || attempt.current.sessionId !== id || attempt.current.text !== text) { + attempt.current = {sessionId: id, text, requestId: crypto.randomUUID()}; + } + const accepted = await acceptChatTurn(id, text, attempt.current.requestId); + if (mounted.current) { + setDraft(""); setTurnId(accepted.turn_id); + setStatus(zh ? "已持久受理,等待结果" : "Durably accepted · awaiting the result"); + } + let terminal = ""; + await streamChatTurn(accepted.events_url, event => { + if (["turn.completed", "turn.interrupted", "turn.failed"].includes(event.kind)) terminal = event.kind; + if (mounted.current && event.kind === "agent.phase") { + setStatus(String(event.payload.label ?? (zh ? "正在处理" : "Working"))); + } + }); + attempt.current = null; + await refresh(id); + if (mounted.current) setStatus(terminal === "turn.interrupted" + ? (zh ? "本次请求已停止" : "This request was stopped") + : terminal === "turn.failed" ? (zh ? "本次执行失败,请查看会话记录。" : "This execution failed; inspect the Session history.") : ""); + } catch (error) { + if (mounted.current) { + setError(String(error)); + setStatus(zh ? "请刷新会话查看受理与执行状态;重试保留同一请求标识。" : "Refresh this Session to check admission and execution; retry retains the request identity."); + } + } finally {if (mounted.current) setBusy(false);} + } + + async function changeSession(mode: "new" | "resume_latest") { + setBusy(true); setError(""); + try {await open(mode);} catch (error) {setError(String(error));} + finally {setBusy(false);} + } + + return
+

{zh ? "只读讨论授权工作区,保留连续上下文。普通聊天不会创建长期 Goal。" : "Discuss an authorized workspace with continuous context and a read grant. Ordinary chat does not create a long-running Goal."}

+ + {!projects.length && !error ?

{zh ? "此宿主尚未提供可用工作区。" : "This host has no available workspace grants."}

: null} +
+ + + {session ? : null} +
+
+ {messages.map(message =>
+ {message.role === "user" ? (zh ? "你" : "You") : (zh ? "助手" : "Assistant")} + +
)} +
+
{event.preventDefault(); void send();}}> +