Refactor assistant page and gateway runtime integration
- Unify execution target switching in app controllers - Enhance assistant page with gateway-aware message handling - Add comprehensive tests for execution target switching and gateway runtime - Integrate gateway settings into settings center Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e88290b98
commit
cb1c176b3f
654
docs/architecture/xworkmate-internal-state-architecture.md
Normal file
654
docs/architecture/xworkmate-internal-state-architecture.md
Normal file
@ -0,0 +1,654 @@
|
||||
XWorkmate App Internal State Architecture
|
||||
Last Updated: 2026-03-22
|
||||
|
||||
Purpose
|
||||
|
||||
This document defines the current internal state model of XWorkmate with a
|
||||
focus on the relationship between:
|
||||
|
||||
- Settings center configuration state
|
||||
- Current assistant session state
|
||||
- Task thread state
|
||||
- Skill state
|
||||
- Execution target / work mode
|
||||
- Model selection
|
||||
- Conversation content
|
||||
|
||||
This file is intended to be the plain-text baseline for future AI-assisted
|
||||
changes. If a new implementation conflicts with this document, the change
|
||||
should be treated as suspicious until the ownership and data flow are clarified.
|
||||
|
||||
Scope note
|
||||
|
||||
This document is written from the Desktop implementation first, because the
|
||||
Desktop controller currently owns the richest runtime and persistence path.
|
||||
Where Web has a parallel implementation with the same state semantics, that
|
||||
mapping is called out explicitly instead of being treated as an afterthought.
|
||||
|
||||
========================================================================
|
||||
1. Core Rule
|
||||
========================================================================
|
||||
|
||||
There are two primary state layers and one derived UI layer.
|
||||
|
||||
Layer A: Settings center configuration state
|
||||
Layer B: Current assistant session state
|
||||
Layer C: Derived UI state
|
||||
|
||||
The most important rule is:
|
||||
|
||||
Settings center state is not the same thing as current session state.
|
||||
|
||||
Settings defines defaults and persisted app-level configuration.
|
||||
Session state defines what the currently selected task thread is actually using.
|
||||
UI should render from the resolved session state, not from settings alone.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Architecture Diagram
|
||||
------------------------------------------------------------------------
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph P["④ Persistence Layer"]
|
||||
SettingsStore["SettingsStore"]
|
||||
SecretStore["SecretStore"]
|
||||
SecureConfigStore["SecureConfigStore"]
|
||||
end
|
||||
|
||||
subgraph C_D["②a AppControllerDesktop (Desktop)"]
|
||||
settings["settings<br/>(persisted snapshot)"]
|
||||
settingsDraft["settingsDraft<br/>(in-memory draft)"]
|
||||
_draftSecretValues["_draftSecretValues"]
|
||||
_pendingApply["_pendingSettingsApply<br/>_pendingGatewayApply<br/>_pendingAiGatewayApply"]
|
||||
_assistantThreadRecords["_assistantThreadRecords[sessionKey]<br/><AssistantThreadRecord>"]
|
||||
_assistantThreadMessages["_assistantThreadMessages[sessionKey]"]
|
||||
_gatewayHistoryCache["_gatewayHistoryCache[sessionKey]"]
|
||||
_localSessionMessages["_localSessionMessages[sessionKey]"]
|
||||
_aiGatewayStreaming["_aiGatewayStreamingTextBySession[sessionKey]"]
|
||||
end
|
||||
|
||||
subgraph C_W["②b AppControllerWeb (Web, parallel)"]
|
||||
settings_W["_settings"]
|
||||
settingsDraft_W["_settingsDraft"]
|
||||
_threadRecords["_threadRecords[sessionKey]<br/><AssistantThreadRecord>"]
|
||||
end
|
||||
|
||||
subgraph R["③ Resolver / Accessor Layer"]
|
||||
executionTargetResolver["assistantExecutionTargetForSession(sessionKey)"]
|
||||
modelResolver["assistantModelForSession(sessionKey)"]
|
||||
discoveredSkillsR["assistantDiscoveredSkillsForSession(sessionKey)"]
|
||||
importedSkillsR["assistantImportedSkillsForSession(sessionKey)"]
|
||||
selectedSkillsR["assistantSelectedSkillKeysForSession(sessionKey)"]
|
||||
connectionStateR["assistantConnectionStateForSession(sessionKey)"]
|
||||
end
|
||||
|
||||
subgraph U["④ UI Layer (Derived, not authoritative)"]
|
||||
subgraph AP["AssistantPage"]
|
||||
_taskSeeds["_taskSeeds<br/>(rendering cache)"]
|
||||
connectionChip["connection chip"]
|
||||
execSelector["execution target selector"]
|
||||
skillPanel["skill panel"]
|
||||
modelLabel["model label"]
|
||||
taskList["task list"]
|
||||
end
|
||||
subgraph SP["SettingsPage"]
|
||||
draftEditor["settingsDraft editor"]
|
||||
saveApply["Save / Apply buttons"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Persistence writes
|
||||
SecretStore -->|"secure secrets"| _draftSecretValues
|
||||
SecureConfigStore -->|"config refs"| settings
|
||||
SettingsStore -->|"persisted snapshot"| settings
|
||||
|
||||
%% Settings draft flow
|
||||
settingsDraft -.->|"_settingsDraftInitialized<br/>? draft : settings"| settings
|
||||
_draftSecretValues -->|flush on Apply| SecretStore
|
||||
_pendingApply -->|Apply triggers| settings
|
||||
|
||||
%% Thread record is the per-session state core
|
||||
_assistantThreadRecords -->|executionTarget<br/>assistantModelId<br/>selectedSkillKeys<br/>discoveredSkills<br/>importedSkills<br/>messageViewMode| R
|
||||
_assistantThreadMessages -->|gateway-backed messages| R
|
||||
_gatewayHistoryCache -->|gateway history| R
|
||||
_localSessionMessages -->|local messages| R
|
||||
_aiGatewayStreaming -->|streaming text| R
|
||||
|
||||
%% Resolver output feeds UI
|
||||
executionTargetResolver -->|currentAssistantExecutionTarget| execSelector
|
||||
executionTargetResolver -->|connection state| connectionChip
|
||||
modelResolver -->|resolved model| modelLabel
|
||||
discoveredSkillsR -->|discovered skills| skillPanel
|
||||
importedSkillsR -->|imported skills| skillPanel
|
||||
selectedSkillsR -->|selected keys| skillPanel
|
||||
connectionStateR -->|connection state| connectionChip
|
||||
|
||||
%% Task list from thread records
|
||||
_assistantThreadRecords -.->|"title / preview<br/>/ status"| _taskSeeds
|
||||
_taskSeeds --> taskList
|
||||
settings -.->|grouping defaults| taskList
|
||||
|
||||
%% Settings page drives draft and apply
|
||||
draftEditor --> settingsDraft
|
||||
saveApply -->|Save = persist| settingsDraft
|
||||
saveApply -->|Apply = runtime sync| _assistantThreadRecords
|
||||
saveApply -->|Apply = runtime sync| settings
|
||||
|
||||
%% Web parallel — same schema, isolated instance
|
||||
C_W -.->|"same AssistantThreadRecord<br/>schema as Desktop"| C_D
|
||||
|
||||
style P fill:#e8f4f8,stroke:#4a90a4,stroke-width:1px
|
||||
style C_D fill:#f0f0f0,stroke:#666,stroke-width:2px
|
||||
style C_W fill:#f9f9f9,stroke:#999,stroke-width:1px,stroke-dasharray:4
|
||||
style R fill:#fff8e1,stroke:#f9a825,stroke-width:1px
|
||||
style U fill:#e8f5e9,stroke:#43a047,stroke-width:1px
|
||||
```
|
||||
|
||||
**How to read this diagram:**
|
||||
|
||||
- **Solid arrows** (`-->`) = authoritative data flow / ownership
|
||||
- **Dashed arrows** (`-.->`) = derived / recomputed read
|
||||
- **Resolver layer** implements the resolution order: thread record field → settings fallback
|
||||
- **Web (`AppControllerWeb`)** maintains its own isolated `_threadRecords` instance; it has the same `AssistantThreadRecord` schema but is a separate runtime copy
|
||||
- **Persistence layer** never flows upward — settings are loaded at bootstrap, drafts are written back on Save, runtime never directly mutates persisted state
|
||||
|
||||
========================================================================
|
||||
2. State Ownership
|
||||
========================================================================
|
||||
|
||||
2.1 Settings center configuration state
|
||||
|
||||
Primary owners:
|
||||
- lib/app/app_controller_desktop.dart
|
||||
- lib/app/app_controller_web.dart
|
||||
|
||||
Primary fields:
|
||||
- settings
|
||||
- settingsDraft
|
||||
- _settingsDraftInitialized
|
||||
- _draftSecretValues
|
||||
- _pendingSettingsApply
|
||||
- _pendingGatewayApply
|
||||
- _pendingAiGatewayApply
|
||||
|
||||
Sources:
|
||||
- settings
|
||||
Persisted global snapshot from SettingsController.snapshot
|
||||
- settingsDraft
|
||||
In-memory draft used by Settings page before save/apply
|
||||
- _settingsDraftInitialized
|
||||
Gate that decides whether settingsDraft should return the in-memory draft or
|
||||
fall back to the persisted settings snapshot
|
||||
- _draftSecretValues
|
||||
Temporary secret draft values before they are persisted into secure storage
|
||||
|
||||
Responsibilities:
|
||||
- Store global default configuration
|
||||
- Persist app-level settings
|
||||
- Persist secure secrets
|
||||
- Make the saved configuration take effect only when Apply is executed
|
||||
|
||||
Important APIs:
|
||||
- saveSettingsDraft(...)
|
||||
- persistSettingsDraft()
|
||||
- applySettingsDraft()
|
||||
- saveSettings(...)
|
||||
|
||||
Important rule:
|
||||
Settings center should define defaults, integrations, and persisted config.
|
||||
It should not be treated as the only truth for the current task thread.
|
||||
|
||||
2.2 Current assistant session state
|
||||
|
||||
Primary owners:
|
||||
- Desktop: lib/app/app_controller_desktop.dart
|
||||
- Web: lib/app/app_controller_web.dart
|
||||
|
||||
Primary in-memory store:
|
||||
- Desktop:
|
||||
- _assistantThreadRecords[sessionKey]
|
||||
- _assistantThreadMessages[sessionKey]
|
||||
- _gatewayHistoryCache[sessionKey]
|
||||
- _aiGatewayStreamingTextBySession[sessionKey]
|
||||
- _localSessionMessages[sessionKey]
|
||||
- Web:
|
||||
- _threadRecords[sessionKey]
|
||||
- _streamingTextBySession[sessionKey]
|
||||
- current record fallback through _currentRecord
|
||||
|
||||
Primary schema:
|
||||
lib/runtime/runtime_models.dart
|
||||
AssistantThreadRecord
|
||||
|
||||
AssistantThreadRecord fields that matter most:
|
||||
- executionTarget
|
||||
- messageViewMode
|
||||
- discoveredSkills
|
||||
- importedSkills
|
||||
- selectedSkillKeys
|
||||
- assistantModelId
|
||||
- gatewayEntryState
|
||||
- messages
|
||||
|
||||
Responsibilities:
|
||||
- Hold per-thread overrides
|
||||
- Isolate thread behavior from other threads
|
||||
- Preserve per-thread mode, skills, model, and content
|
||||
- Allow thread state to differ from global default settings
|
||||
|
||||
Important rule:
|
||||
If a value exists in AssistantThreadRecord for a session, that thread-level
|
||||
value wins over the global settings default.
|
||||
|
||||
Web note:
|
||||
The Web controller uses the same AssistantThreadRecord schema and the same
|
||||
basic ownership rule, but the runtime-backed data sources are simpler than
|
||||
Desktop. In Web, relay/direct-AI conversation state is resolved through
|
||||
_threadRecords, _currentRecord, and browser/session repositories rather than
|
||||
Desktop runtime controllers.
|
||||
|
||||
2.3 Derived UI state
|
||||
|
||||
Primary owners:
|
||||
- lib/features/assistant/assistant_page.dart
|
||||
- lib/features/settings/settings_page.dart
|
||||
|
||||
Examples:
|
||||
- task list groups
|
||||
- top-right connection chip
|
||||
- bottom execution target selector
|
||||
- empty-state card
|
||||
- skill panel
|
||||
- model label
|
||||
- task row labels
|
||||
|
||||
Responsibilities:
|
||||
- Display resolved state
|
||||
- Never become the authoritative source of truth
|
||||
|
||||
Important rule:
|
||||
UI must render from resolved state, not invent its own parallel mode/model/skill
|
||||
state.
|
||||
|
||||
========================================================================
|
||||
3. Resolution Priority
|
||||
========================================================================
|
||||
|
||||
3.1 Execution target / work mode
|
||||
|
||||
Meaning:
|
||||
- AI Gateway only
|
||||
- Local OpenClaw Gateway
|
||||
- Remote OpenClaw Gateway
|
||||
|
||||
Primary resolver:
|
||||
assistantExecutionTargetForSession(sessionKey)
|
||||
|
||||
Resolution order:
|
||||
1. AssistantThreadRecord.executionTarget for that session
|
||||
2. settings.assistantExecutionTarget
|
||||
|
||||
Interpretation:
|
||||
- settings.assistantExecutionTarget is the default
|
||||
- thread.executionTarget is the actual current-session override
|
||||
|
||||
Consequence:
|
||||
Changing settings alone does not automatically mean the current thread display
|
||||
has changed unless the current thread record is also synchronized.
|
||||
|
||||
3.2 Model
|
||||
|
||||
Primary resolver:
|
||||
assistantModelForSession(sessionKey)
|
||||
|
||||
Resolution order:
|
||||
1. AssistantThreadRecord.assistantModelId
|
||||
2. resolved model for current execution target
|
||||
|
||||
Fallback rules:
|
||||
- If target is aiGatewayOnly, use resolvedAiGatewayModel
|
||||
- If target is local or remote, use resolvedDefaultModel
|
||||
|
||||
Interpretation:
|
||||
Model selection is thread-bound when explicitly set, otherwise inherited from
|
||||
target-specific defaults.
|
||||
|
||||
3.3 Skills
|
||||
|
||||
Primary owner:
|
||||
AssistantThreadRecord
|
||||
|
||||
Fields:
|
||||
- discoveredSkills
|
||||
- importedSkills
|
||||
- selectedSkillKeys
|
||||
|
||||
Resolution rule:
|
||||
- The selected/imported/discovered skills shown in UI belong to the current
|
||||
session thread
|
||||
- Settings center must not be treated as the source of selected thread skills
|
||||
|
||||
3.4 Conversation content
|
||||
|
||||
Primary sources:
|
||||
- _chatController.messages
|
||||
- _gatewayHistoryCache[sessionKey]
|
||||
- _assistantThreadMessages[sessionKey]
|
||||
- _localSessionMessages[sessionKey]
|
||||
- _aiGatewayStreamingTextBySession[sessionKey]
|
||||
|
||||
Resolution rule:
|
||||
- Gateway-backed thread content and AI-Gateway-only thread content do not come
|
||||
from the same runtime path
|
||||
- The UI composes the final visible conversation from multiple stores depending
|
||||
on the current thread target
|
||||
|
||||
3.5 Task thread list
|
||||
|
||||
Primary source:
|
||||
- assistantSessions
|
||||
- _taskSeeds in AssistantPage as a rendering cache
|
||||
|
||||
Important rule:
|
||||
Task list is a derived representation of thread/session state.
|
||||
Task list must not become the owner of mode, model, or skill state.
|
||||
|
||||
Implementation note:
|
||||
_taskSeeds is still a cache of derived values such as title, preview, status,
|
||||
owner, surface, and executionTarget. It is not an authoritative source, but it
|
||||
can become stale if source mutations do not eventually trigger task
|
||||
recomputation.
|
||||
|
||||
========================================================================
|
||||
4. Data Flow
|
||||
========================================================================
|
||||
|
||||
4.1 Settings center flow
|
||||
|
||||
Edit in Settings page
|
||||
-> settingsDraft changes
|
||||
-> Save
|
||||
-> persisted settings + secure secrets update
|
||||
-> Apply
|
||||
-> current configuration takes effect immediately
|
||||
|
||||
Meaning of buttons:
|
||||
- Save
|
||||
Persist configuration only
|
||||
Do not trigger runtime connection or model sync
|
||||
- Apply
|
||||
Make the current saved configuration take effect immediately
|
||||
This may connect a gateway, switch execution behavior, or sync AI Gateway
|
||||
catalog
|
||||
|
||||
4.2 Session flow
|
||||
|
||||
Select thread
|
||||
-> switchSession(sessionKey)
|
||||
-> resolve thread executionTarget
|
||||
-> resolve thread model
|
||||
-> resolve thread skills
|
||||
-> apply thread execution target
|
||||
-> reload conversation content for the chosen thread
|
||||
|
||||
Create new thread
|
||||
-> inherit current thread executionTarget
|
||||
-> inherit current thread messageViewMode
|
||||
-> initialize AssistantThreadRecord
|
||||
-> switch to the new thread
|
||||
|
||||
Change execution target from Assistant page
|
||||
-> update current thread record.executionTarget
|
||||
-> optionally persist new global default selection
|
||||
-> reconnect / disconnect runtime as needed
|
||||
-> refresh skills and derived UI
|
||||
|
||||
========================================================================
|
||||
5. Dependency Graph
|
||||
========================================================================
|
||||
|
||||
5.1 Settings center depends on
|
||||
|
||||
- SettingsController snapshot and secure refs
|
||||
- SecureConfigStore / SettingsStore / SecretStore
|
||||
- Runtime side-effect handlers in AppController
|
||||
|
||||
5.2 Current assistant session depends on
|
||||
|
||||
- _assistantThreadRecords
|
||||
- runtime snapshot / gateway runtime
|
||||
- current selected session key
|
||||
- persisted thread records restored during bootstrap
|
||||
|
||||
5.3 Task list depends on
|
||||
|
||||
- assistantSessions
|
||||
- current session key
|
||||
- thread executionTarget
|
||||
- per-thread preview / title / status
|
||||
|
||||
5.4 Skill panel depends on
|
||||
|
||||
- current session key
|
||||
- assistantImportedSkillsForSession(sessionKey)
|
||||
- assistantSelectedSkillKeysForSession(sessionKey)
|
||||
- assistantDiscoveredSkillsForSession(sessionKey)
|
||||
|
||||
5.5 Top-right status chip depends on
|
||||
|
||||
- current session key
|
||||
- assistantConnectionStateForSession(currentSessionKey)
|
||||
- runtime connection snapshot
|
||||
- session executionTarget
|
||||
|
||||
5.6 Bottom execution target selector depends on
|
||||
|
||||
- currentAssistantExecutionTarget
|
||||
- thread executionTarget for current session
|
||||
|
||||
5.7 Model label depends on
|
||||
|
||||
- assistantModelForSession(currentSessionKey)
|
||||
- resolvedAiGatewayModel
|
||||
- resolvedDefaultModel
|
||||
|
||||
========================================================================
|
||||
6. Correct Sync Rules
|
||||
========================================================================
|
||||
|
||||
6.1 What Save should update
|
||||
|
||||
Save should update:
|
||||
- persisted settings snapshot
|
||||
- persisted secure secrets
|
||||
- pending apply markers
|
||||
|
||||
Save should not update:
|
||||
- live runtime connection by itself
|
||||
- current thread execution target by itself
|
||||
- task list grouping by itself unless the task list is explicitly reading the
|
||||
global defaults
|
||||
|
||||
6.2 What Apply must update when execution behavior changes
|
||||
|
||||
If Apply changes assistant execution behavior, it must synchronize:
|
||||
|
||||
- settings.assistantExecutionTarget
|
||||
- current thread AssistantThreadRecord.executionTarget
|
||||
- runtime connection / disconnection path
|
||||
- session-specific skill visibility if mode changes
|
||||
- derived UI:
|
||||
- top-right chip
|
||||
- bottom selector
|
||||
- empty-state card
|
||||
- task list grouping
|
||||
|
||||
6.3 What thread switching must update
|
||||
|
||||
switchSession(sessionKey) must synchronize:
|
||||
|
||||
- current thread executionTarget
|
||||
- current thread message view mode
|
||||
- current thread model
|
||||
- current thread selected/imported/discovered skills
|
||||
- current thread conversation content source
|
||||
- current thread connection label
|
||||
|
||||
6.4 What task list must never do
|
||||
|
||||
Task list must never:
|
||||
- own executionTarget
|
||||
- own model selection
|
||||
- own selected skills
|
||||
- become the source of truth for session state
|
||||
|
||||
Task list should only display resolved session state.
|
||||
|
||||
========================================================================
|
||||
7. Known Failure Modes
|
||||
========================================================================
|
||||
|
||||
7.1 Settings center and current session diverge
|
||||
|
||||
Symptom:
|
||||
- User saves/applies a new mode in Settings
|
||||
- Top-right chip still shows old mode
|
||||
- Bottom selector still shows old mode
|
||||
|
||||
Typical cause:
|
||||
- settings.assistantExecutionTarget updated
|
||||
- current session AssistantThreadRecord.executionTarget not updated
|
||||
|
||||
7.2 Task list grouping is wrong
|
||||
|
||||
Symptom:
|
||||
- Task appears under the wrong mode group
|
||||
- Group count does not match the visible current thread target
|
||||
|
||||
Typical cause:
|
||||
- task seed / task entry is rendering stale thread executionTarget
|
||||
- _taskSeeds still holds stale derived values because the mutation path did not
|
||||
trigger task recomputation
|
||||
|
||||
7.3 Skill panel leaks across threads
|
||||
|
||||
Symptom:
|
||||
- A skill selected in one task appears selected in another unrelated task
|
||||
|
||||
Typical cause:
|
||||
- selectedSkillKeys not isolated to AssistantThreadRecord
|
||||
- UI reading global or shared state instead of current session record
|
||||
|
||||
7.4 Model label is stale
|
||||
|
||||
Symptom:
|
||||
- Current thread changed, but header/composer still shows previous model
|
||||
|
||||
Typical cause:
|
||||
- UI not recomputed from assistantModelForSession(currentSessionKey)
|
||||
|
||||
7.5 Conversation content source mismatch
|
||||
|
||||
Symptom:
|
||||
- Thread switches, but visible content still reflects previous mode/path
|
||||
|
||||
Typical cause:
|
||||
- current session switched
|
||||
- content source not switched between gateway-backed history and AI-Gateway-only
|
||||
cache
|
||||
|
||||
========================================================================
|
||||
8. Canonical Ownership Table
|
||||
========================================================================
|
||||
|
||||
Field: settingsDraft
|
||||
Owner: Settings center
|
||||
Scope: global draft
|
||||
|
||||
Field: settings
|
||||
Owner: persisted settings snapshot
|
||||
Scope: global persisted config
|
||||
|
||||
Field: secret drafts
|
||||
Owner: Settings center
|
||||
Scope: global draft, secure persistence path
|
||||
|
||||
Field: executionTarget
|
||||
Owner: AssistantThreadRecord first, settings fallback second
|
||||
Scope: thread
|
||||
|
||||
Field: assistantModelId
|
||||
Owner: AssistantThreadRecord first, target-specific fallback second
|
||||
Scope: thread
|
||||
|
||||
Field: selectedSkillKeys
|
||||
Owner: AssistantThreadRecord
|
||||
Scope: thread
|
||||
|
||||
Field: importedSkills
|
||||
Owner: AssistantThreadRecord
|
||||
Scope: thread
|
||||
|
||||
Field: discoveredSkills
|
||||
Owner: AssistantThreadRecord
|
||||
Scope: thread
|
||||
|
||||
Field: messageViewMode
|
||||
Owner: AssistantThreadRecord
|
||||
Scope: thread
|
||||
|
||||
Field: conversation messages
|
||||
Owner: runtime/message stores depending on target
|
||||
Scope: thread
|
||||
|
||||
Field: task list group
|
||||
Owner: derived UI only
|
||||
Scope: visual grouping
|
||||
|
||||
========================================================================
|
||||
9. Modification Rules For Future AI Changes
|
||||
========================================================================
|
||||
|
||||
Before changing Assistant, Settings, or Gateway behavior, check:
|
||||
|
||||
1. Is this a settings default or a thread override?
|
||||
2. If a setting is applied, should the current thread record be synchronized?
|
||||
3. If a thread changes, which derived UI surfaces must refresh?
|
||||
4. Is the task list only displaying state, or accidentally owning it?
|
||||
5. Does the change preserve per-thread isolation for:
|
||||
- mode
|
||||
- model
|
||||
- skills
|
||||
- content
|
||||
|
||||
If a proposed change cannot answer those five questions clearly, the
|
||||
implementation is not ready.
|
||||
|
||||
========================================================================
|
||||
10. Relevant Files
|
||||
========================================================================
|
||||
|
||||
Global settings and apply flow:
|
||||
- lib/app/app_controller_desktop.dart
|
||||
- lib/app/app_controller_web.dart
|
||||
- lib/features/settings/settings_page.dart
|
||||
|
||||
Session/thread state:
|
||||
- lib/runtime/runtime_models.dart
|
||||
- lib/app/app_controller_desktop.dart
|
||||
- lib/app/app_controller_web.dart
|
||||
|
||||
Assistant UI:
|
||||
- lib/features/assistant/assistant_page.dart
|
||||
|
||||
Persistence:
|
||||
- lib/runtime/secure_config_store.dart
|
||||
- lib/runtime/settings_store.dart
|
||||
- lib/runtime/secret_store.dart
|
||||
- lib/runtime/legacy_settings_recovery.dart
|
||||
|
||||
Supporting architecture docs:
|
||||
- docs/architecture/assistant-thread-information-architecture.md
|
||||
- docs/architecture/xworkmate-integrations.md
|
||||
|
||||
End of document.
|
||||
@ -1352,6 +1352,8 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
if (nextTarget == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
await discoverGatewayOnlySkillsForSession(nextSessionKey);
|
||||
} else {
|
||||
await dismissDiscoveredSkillsForSession(nextSessionKey);
|
||||
}
|
||||
_recomputeTasks();
|
||||
}
|
||||
@ -1883,8 +1885,8 @@ class AppController extends ChangeNotifier {
|
||||
_settingsDraftInitialized = true;
|
||||
_pendingSettingsApply = true;
|
||||
_settingsDraftStatusMessage = appText(
|
||||
'已保存设置,等待应用。',
|
||||
'Settings saved. Apply to activate runtime changes.',
|
||||
'已保存配置,不立即生效。',
|
||||
'Settings saved. They do not take effect until Apply.',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
@ -1923,8 +1925,8 @@ class AppController extends ChangeNotifier {
|
||||
_settingsDraft = settings;
|
||||
_settingsDraftInitialized = true;
|
||||
_settingsDraftStatusMessage = appText(
|
||||
'已应用全部设置。',
|
||||
'All saved settings have been applied.',
|
||||
'已按当前配置生效。',
|
||||
'The current configuration is now in effect.',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
@ -2516,22 +2518,29 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _applyPersistedGatewaySettings(SettingsSnapshot snapshot) async {
|
||||
if (snapshot.assistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
if (_runtime.isConnected) {
|
||||
try {
|
||||
await disconnectGateway();
|
||||
} catch (_) {
|
||||
// Keep saved settings even when runtime teardown is noisy.
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _connectProfile(snapshot.gateway);
|
||||
} catch (_) {
|
||||
// Save/apply should keep persisted config even if the immediate
|
||||
// connection attempt fails.
|
||||
final target = _sanitizeExecutionTarget(snapshot.assistantExecutionTarget);
|
||||
final sessionKey = _normalizedAssistantSessionKey(
|
||||
_sessionsController.currentSessionKey,
|
||||
);
|
||||
_upsertAssistantThreadRecord(
|
||||
sessionKey,
|
||||
executionTarget: target,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
_recomputeTasks();
|
||||
_notifyIfActive();
|
||||
await _applyAssistantExecutionTarget(
|
||||
target,
|
||||
sessionKey: sessionKey,
|
||||
persistDefaultSelection: false,
|
||||
);
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
await discoverGatewayOnlySkillsForSession(sessionKey);
|
||||
} else {
|
||||
await dismissDiscoveredSkillsForSession(sessionKey);
|
||||
}
|
||||
_recomputeTasks();
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> _applyPersistedAiGatewaySettings(
|
||||
|
||||
@ -479,8 +479,8 @@ class AppController extends ChangeNotifier {
|
||||
_settingsDraftInitialized = true;
|
||||
_pendingSettingsApply = true;
|
||||
_settingsDraftStatusMessage = appText(
|
||||
'已保存设置,等待应用。',
|
||||
'Settings saved. Apply to activate runtime changes.',
|
||||
'已保存配置,不立即生效。',
|
||||
'Settings saved. They do not take effect until Apply.',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
@ -501,8 +501,8 @@ class AppController extends ChangeNotifier {
|
||||
_settingsDraftInitialized = true;
|
||||
_pendingSettingsApply = false;
|
||||
_settingsDraftStatusMessage = appText(
|
||||
'已应用全部设置。',
|
||||
'All saved settings have been applied.',
|
||||
'已按当前配置生效。',
|
||||
'The current configuration is now in effect.',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -1855,7 +1855,7 @@ class _ConversationArea extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantTaskRail extends StatelessWidget {
|
||||
class _AssistantTaskRail extends StatefulWidget {
|
||||
const _AssistantTaskRail({
|
||||
super.key,
|
||||
required this.controller,
|
||||
@ -1883,10 +1883,19 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
final Future<void> Function(String sessionKey) onArchiveTask;
|
||||
final Future<void> Function(_AssistantTaskEntry entry) onRenameTask;
|
||||
|
||||
@override
|
||||
State<_AssistantTaskRail> createState() => _AssistantTaskRailState();
|
||||
}
|
||||
|
||||
class _AssistantTaskRailState extends State<_AssistantTaskRail> {
|
||||
final Set<AssistantExecutionTarget> _expandedGroups =
|
||||
<AssistantExecutionTarget>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
final tasks = widget.tasks;
|
||||
final groupedTasks = _groupTasksForRail(tasks);
|
||||
final runningCount = tasks
|
||||
.where((task) => _normalizedTaskStatus(task.status) == 'running')
|
||||
@ -1911,16 +1920,16 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('assistant-task-search'),
|
||||
controller: searchController,
|
||||
onChanged: onQueryChanged,
|
||||
controller: widget.searchController,
|
||||
onChanged: widget.onQueryChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText('搜索任务', 'Search tasks'),
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: query.isEmpty
|
||||
suffixIcon: widget.query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: appText('清除搜索', 'Clear search'),
|
||||
onPressed: onClearQuery,
|
||||
onPressed: widget.onClearQuery,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
@ -1931,7 +1940,7 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
key: const Key('assistant-task-refresh'),
|
||||
tooltip: appText('刷新任务', 'Refresh tasks'),
|
||||
onPressed: () async {
|
||||
await onRefreshTasks();
|
||||
await widget.onRefreshTasks();
|
||||
},
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
@ -1943,7 +1952,7 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const Key('assistant-new-task-button'),
|
||||
onPressed: () async {
|
||||
await onCreateTask();
|
||||
await widget.onCreateTask();
|
||||
},
|
||||
icon: const Icon(Icons.edit_note_rounded),
|
||||
label: Text(appText('新对话', 'New conversation')),
|
||||
@ -1974,7 +1983,7 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
),
|
||||
_MetaPill(
|
||||
label:
|
||||
'${appText('技能', 'Skills')} ${controller.skills.length}',
|
||||
'${appText('技能', 'Skills')} ${widget.controller.skills.length}',
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
),
|
||||
],
|
||||
@ -2002,68 +2011,75 @@ class _AssistantTaskRail extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: tasks.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
appText(
|
||||
'没有匹配的任务,试试新建一个。',
|
||||
'No matching tasks. Start a new one.',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||
itemCount: groupedTasks.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final group = groupedTasks[index];
|
||||
final expanded = _expandedGroups.contains(group.executionTarget);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_AssistantTaskGroupHeader(
|
||||
executionTarget: group.executionTarget,
|
||||
count: group.items.length,
|
||||
expanded: expanded,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (expanded) {
|
||||
_expandedGroups.remove(group.executionTarget);
|
||||
} else {
|
||||
_expandedGroups.add(group.executionTarget);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||
itemCount: groupedTasks.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final group = groupedTasks[index];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_AssistantTaskGroupHeader(
|
||||
executionTarget: group.executionTarget,
|
||||
count: group.items.length,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (
|
||||
var itemIndex = 0;
|
||||
itemIndex < group.items.length;
|
||||
itemIndex++
|
||||
) ...[
|
||||
if (itemIndex > 0) const SizedBox(height: 4),
|
||||
_AssistantTaskTile(
|
||||
entry: group.items[itemIndex],
|
||||
archiveEnabled:
|
||||
_normalizedTaskStatus(
|
||||
group.items[itemIndex].status,
|
||||
) !=
|
||||
'running',
|
||||
onTap: () async {
|
||||
await onSelectTask(
|
||||
group.items[itemIndex].sessionKey,
|
||||
);
|
||||
},
|
||||
onRename: () async {
|
||||
await onRenameTask(group.items[itemIndex]);
|
||||
},
|
||||
onArchive: () async {
|
||||
await onArchiveTask(
|
||||
group.items[itemIndex].sessionKey,
|
||||
);
|
||||
},
|
||||
if (expanded) ...[
|
||||
const SizedBox(height: 4),
|
||||
if (group.items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 8, 4),
|
||||
child: Text(
|
||||
appText('当前分组没有任务。', 'No tasks in this group.'),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
for (
|
||||
var itemIndex = 0;
|
||||
itemIndex < group.items.length;
|
||||
itemIndex++
|
||||
) ...[
|
||||
if (itemIndex > 0) const SizedBox(height: 4),
|
||||
_AssistantTaskTile(
|
||||
entry: group.items[itemIndex],
|
||||
archiveEnabled:
|
||||
_normalizedTaskStatus(
|
||||
group.items[itemIndex].status,
|
||||
) !=
|
||||
'running',
|
||||
onTap: () async {
|
||||
await widget.onSelectTask(
|
||||
group.items[itemIndex].sessionKey,
|
||||
);
|
||||
},
|
||||
onRename: () async {
|
||||
await widget.onRenameTask(group.items[itemIndex]);
|
||||
},
|
||||
onArchive: () async {
|
||||
await widget.onArchiveTask(
|
||||
group.items[itemIndex].sessionKey,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -2086,7 +2102,6 @@ List<_AssistantTaskGroup> _groupTasksForRail(List<_AssistantTaskEntry> tasks) {
|
||||
items: grouped[target]!,
|
||||
),
|
||||
)
|
||||
.where((group) => group.items.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@ -2198,41 +2213,60 @@ class _AssistantTaskGroupHeader extends StatelessWidget {
|
||||
const _AssistantTaskGroupHeader({
|
||||
required this.executionTarget,
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
key: ValueKey<String>('assistant-task-group-${executionTarget.name}'),
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(executionTarget.icon, size: 14, color: palette.textMuted),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
executionTarget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: ValueKey<String>('assistant-task-group-${executionTarget.name}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
expanded
|
||||
? Icons.keyboard_arrow_down_rounded
|
||||
: Icons.keyboard_arrow_right_rounded,
|
||||
size: 16,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(executionTarget.icon, size: 14, color: palette.textMuted),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
executionTarget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'$count',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'$count',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -378,13 +378,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
? message
|
||||
: hasDraft
|
||||
? appText(
|
||||
'当前存在未保存草稿。保存会持久化配置,但不会触发连接或模型同步。',
|
||||
'There are unsaved drafts. Save persists settings without connecting or syncing models.',
|
||||
'当前存在未保存草稿。保存:仅保存配置,不立即生效。',
|
||||
'There are unsaved drafts. Save persists configuration only and does not apply it immediately.',
|
||||
)
|
||||
: hasPendingApply
|
||||
? appText(
|
||||
'当前存在已保存但未应用的更改。点击应用会触发连接和模型同步。',
|
||||
'There are saved changes waiting to be applied. Apply will trigger connection and model sync.',
|
||||
'当前存在已保存但未应用的更改。应用:立即按当前配置生效。',
|
||||
'There are saved changes waiting to be applied. Apply makes the current configuration take effect immediately.',
|
||||
)
|
||||
: (message.isEmpty
|
||||
? appText(
|
||||
@ -933,8 +933,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'统一编辑本地 / 远程 OpenClaw Gateway 的连接参数。保存只持久化,应用才会按当前模式发起连接或切换为仅 AI Gateway。',
|
||||
'Edit local and remote OpenClaw gateway settings in one place. Save persists only; Apply connects or switches to AI Gateway-only mode.',
|
||||
'统一编辑本地 / 远程 OpenClaw Gateway 的连接参数。保存:仅保存配置,不立即生效。应用:立即按当前配置生效。',
|
||||
'Edit local and remote OpenClaw gateway settings in one place. Save persists configuration only and does not apply it immediately. Apply makes the current configuration take effect immediately.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
@ -2602,7 +2602,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final profile = _buildGatewayDraftProfile(settings);
|
||||
final nextSettings = settings.copyWith(
|
||||
gateway: profile,
|
||||
assistantExecutionTarget: _assistantExecutionTargetForMode(profile.mode),
|
||||
);
|
||||
await _saveSettings(controller, nextSettings);
|
||||
if (!mounted) {
|
||||
|
||||
@ -199,6 +199,8 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
fields: connectAuthFields,
|
||||
sources: connectAuthSources,
|
||||
);
|
||||
final usedStoredDeviceTokenOnly =
|
||||
sharedToken.isEmpty && deviceToken.isNotEmpty;
|
||||
|
||||
if (endpoint == null) {
|
||||
_appendLog(
|
||||
@ -339,6 +341,20 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
deviceId: identity.deviceId,
|
||||
role: 'operator',
|
||||
);
|
||||
} else if (usedStoredDeviceTokenOnly &&
|
||||
_isPairingRequiredError(
|
||||
runtimeError?.code,
|
||||
runtimeError?.detailCode,
|
||||
)) {
|
||||
await _store.clearDeviceToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: 'operator',
|
||||
);
|
||||
_appendLog(
|
||||
'warn',
|
||||
'auth',
|
||||
'cleared stale device token after pairing-required response',
|
||||
);
|
||||
}
|
||||
if (!_shouldAutoReconnect(runtimeError)) {
|
||||
_suppressReconnect = true;
|
||||
@ -1257,6 +1273,13 @@ class GatewayRuntime extends ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isPairingRequiredError(String? code, String? detailCode) {
|
||||
final resolvedCode = code?.trim().toUpperCase();
|
||||
final resolvedDetailCode = detailCode?.trim().toUpperCase();
|
||||
return resolvedCode == 'NOT_PAIRED' ||
|
||||
resolvedDetailCode == 'PAIRING_REQUIRED';
|
||||
}
|
||||
|
||||
Future<void> _closeSocket() async {
|
||||
_reconnectTimer?.cancel();
|
||||
final subscription = _socketSubscription;
|
||||
|
||||
@ -62,6 +62,11 @@ void main() {
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('assistant-task-group-local')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
@ -138,6 +143,11 @@ void main() {
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('assistant-task-group-local')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey<String>('assistant-task-item-main')),
|
||||
);
|
||||
@ -225,6 +235,44 @@ void main() {
|
||||
);
|
||||
}, skip: true);
|
||||
|
||||
testWidgets('AssistantPage shows three collapsed task groups by default', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-task-group-aiGatewayOnly')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-task-group-local')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-task-group-remote')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-task-item-main')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('assistant-task-group-local')),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-task-item-main')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('AssistantPage can switch unified side pane tabs and collapse', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -402,9 +450,9 @@ void main() {
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(find.text('仅 AI Gateway'), findsOneWidget);
|
||||
expect(find.text('仅 AI Gateway'), findsWidgets);
|
||||
expect(find.text('本地 OpenClaw Gateway'), findsWidgets);
|
||||
expect(find.text('远程 OpenClaw Gateway'), findsOneWidget);
|
||||
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);
|
||||
|
||||
await tester.tap(find.text('仅 AI Gateway').last);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
@ -393,6 +393,78 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController applySettingsDraft syncs the active session execution target',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-apply-settings-sync-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.local,
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
gateway: controller.settings.gateway.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'openclaw.svc.plus',
|
||||
port: 443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
|
||||
await controller.saveSettingsDraft(
|
||||
controller.settingsDraft.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.remote,
|
||||
),
|
||||
);
|
||||
await controller.applySettingsDraft();
|
||||
|
||||
expect(
|
||||
controller.currentAssistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
controller.assistantExecutionTargetForSession(controller.currentSessionKey),
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'openclaw.svc.plus:443',
|
||||
);
|
||||
expect(gateway.connectedProfiles.last.mode, RuntimeConnectionMode.remote);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController does not leak the local endpoint into remote thread status while reconnecting',
|
||||
() async {
|
||||
|
||||
@ -212,6 +212,64 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'GatewayRuntime clears a stale stored device token after NOT_PAIRED',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = SecureConfigStore();
|
||||
final identityStore = DeviceIdentityStore(store);
|
||||
final identity = await identityStore.loadOrCreate();
|
||||
await store.saveDeviceToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: 'operator',
|
||||
token: 'stale-device-token',
|
||||
);
|
||||
final runtime = GatewayRuntime(
|
||||
store: store,
|
||||
identityStore: identityStore,
|
||||
);
|
||||
final server = await _FakeGatewayRuntimeServer.start(
|
||||
connectErrorCode: 'NOT_PAIRED',
|
||||
connectErrorDetailCode: 'PAIRING_REQUIRED',
|
||||
connectErrorMessage: 'pairing required',
|
||||
closeAfterConnectError: true,
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
addTearDown(server.close);
|
||||
|
||||
await expectLater(
|
||||
() => runtime.connectProfile(
|
||||
GatewayConnectionProfile.defaults().copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
tls: false,
|
||||
useSetupCode: false,
|
||||
),
|
||||
),
|
||||
throwsA(isA<GatewayRuntimeException>()),
|
||||
);
|
||||
|
||||
expect(server.connectAuth?['token'], 'stale-device-token');
|
||||
expect(server.connectAuth?['deviceToken'], 'stale-device-token');
|
||||
expect(
|
||||
await store.loadDeviceToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: 'operator',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
runtime.logs.any(
|
||||
(entry) =>
|
||||
entry.category == 'auth' &&
|
||||
entry.message.contains('cleared stale device token'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntimeServer {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user