Refine workspace home, provider profiles, and knowledge settings
Separate the Meldwork workspace home from Settings, tighten responsive sidebar and Provider layouts, preserve Agent-only conversation outlines, and refresh transparent knowledge and brand assets. Add encrypted per-Agent Official, OpenRouter, and Custom Provider profiles with migration, narrow activation IPC, native CLI readiness preservation, and OpenClaw native configuration fallback. Improve real local knowledge-source status and settings guidance. Verified with frontend 89/89 tests, desktop 268/268 tests, web and desktop builds, desktop arm64 pack, runtime Provider checks at 810x988, and git diff --check.
No files matched your search
@@ -1,99 +1,151 @@
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/meldwork-mark.svg" alt="Meldwork" width="132">
|
||||
<img src="frontend/public/logos/meldwork-readme-banner-en.png" alt="Meldwork - Agents change. Work continues." width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
# Meldwork
|
||||
|
||||
**Bring the local Agent CLIs already on your machine into one persistent workspace.**
|
||||
**Agents change. Work continues.**
|
||||
|
||||
Meldwork is building an open, local-first work system for agents across vendors. The current MVP brings multiple local AI Agent CLIs into the same local conversation and working directory, discovers the Agents already available on your computer, keeps conversations persistent, preserves resumable native sessions where compatible CLIs expose them, and coordinates manual or bounded automatic discussions without requiring a hosted orchestration service.
|
||||
Meldwork is a local-first desktop workspace for general-purpose AI agents.
|
||||
|
||||
The long-term product direction is an open-source, local-first work system for agents across vendors. This repository is still `UNLICENSED`; the public open-source launch must not happen until a project license is selected and added.
|
||||
AI agents are no longer one narrow product category. Tools that began with coding, research, personal assistance, or operations are converging into general-purpose systems that can reason, use tools, handle files, and carry multi-step work. People will use more than one of them. The work still needs one place to continue.
|
||||
|
||||
## Product Direction
|
||||
Meldwork is not another Agent, and it is not a coding-only Agent aggregator. It keeps conversations, compatible native sessions, collaboration state, and human control in one persistent workspace while work moves between different Agents.
|
||||
|
||||
The target product promise is **Different agents. Work you can verify.** It describes the evidence-and-acceptance loop Meldwork intends to build, not a claim that the current MVP already verifies Agent output.
|
||||
> **Current MVP:** Meldwork connects the supported local Agent CLIs listed below. The product direction is an open connector layer for local, cloud, custom, and channel-native Agents, but arbitrary Agent connectors are not shipped yet. This repository also remains `UNLICENSED` until a public software license is selected.
|
||||
|
||||
The current MVP proves local Agent discovery, guarded execution, persistent conversations, and native-session continuity where upstream CLIs support it. The next milestone is not a larger adapter count; it is an outcome loop that turns Agent activity into work a user can inspect and adopt:
|
||||
## Better Agents do not solve fragmented work
|
||||
|
||||
When real work moves between Agents, users still have to:
|
||||
|
||||
- rebuild context across disconnected sessions and interfaces;
|
||||
- collect decisions, files, and intermediate results from different places;
|
||||
- decide manually which Agent should answer, challenge, revise, or continue;
|
||||
- remember which Agent still holds which part of the task;
|
||||
- reconcile different Provider traffic, permissions, and tool behavior;
|
||||
- treat a fluent answer as completion because there is no durable work record.
|
||||
|
||||
This affects research, planning, writing, operations, analysis, design, development, and review. Coding is one use case, not the product boundary.
|
||||
|
||||
## One workspace for the work
|
||||
|
||||
| Fragmented Agent use | With the current Meldwork MVP |
|
||||
| --- | --- |
|
||||
| Context trapped in separate sessions | Persistent direct and group conversations in one local workspace |
|
||||
| Re-explaining the task after every switch | Native-session continuity where the upstream Agent exposes a compatible resume mechanism |
|
||||
| Manually routing work across several interfaces | Direct responses or user-bounded multi-Agent discussions with visible run state |
|
||||
| Unclear write and Provider behavior | Explicit per-Agent Provider profiles and visible per-conversation write mode where enforceable |
|
||||
| A hosted orchestration account becomes the new dependency | Groups, messages, and orchestration state remain on the user's machine |
|
||||
|
||||
Local-first does not mean every model runs offline. Network traffic still follows the Agent and Provider configuration the user selects. Meldwork keeps its workspace and control state local.
|
||||
|
||||
## General Agents, modeled by real capabilities
|
||||
|
||||
Codex, Claude Code, Hermes, OpenClaw, and similar tools may have different origins, but their capabilities increasingly extend beyond a single coding workflow. Meldwork treats them as Agents and keeps their actual differences explicit: supported inputs, Skills, session behavior, Provider configuration, permissions, and output handling.
|
||||
|
||||
The name of an upstream tool does not define Meldwork's category. Continuity of work across Agents does.
|
||||
|
||||
## What works today
|
||||
|
||||
### Discover supported local Agents
|
||||
|
||||
Refresh one catalog to find supported Agent CLIs already installed or available through an allowlisted installer recipe. Executable paths stay in the Electron main process.
|
||||
|
||||
### Keep work in persistent conversations
|
||||
|
||||
Create multiple direct conversations for the same Agent, return later, or bring selected Agents into a group around the same body of work.
|
||||
|
||||
### Continue native sessions where possible
|
||||
|
||||
For compatible CLI versions, Meldwork keeps one logical native session per Agent and conversation. A resumed Agent receives only the group messages it has not already seen.
|
||||
|
||||
### Bring in another Agent without rebuilding the workflow
|
||||
|
||||
Run one response or a bounded automatic discussion from 1 to 10 rounds. Each Agent has a visible queued, running, completed, or failed state, and the user decides what to accept.
|
||||
|
||||
### Carry useful context and outputs
|
||||
|
||||
Reference validated local Skills with `@`, attach supported PNG or JPEG images, and collect supported image, audio, or video outputs from the authorized workspace's `.meldwork-output` directory. Capability checks remain Agent-specific.
|
||||
|
||||
### Keep execution boundaries explicit
|
||||
|
||||
Executable paths, attachment paths, session references, and child processes stay in the Electron main process. Compatible Provider credentials use operating-system-backed secure storage and are not returned to the renderer after saving.
|
||||
|
||||
## Any Agent is the direction, not the current claim
|
||||
|
||||
The product principle is simple:
|
||||
|
||||
> **If it can act as an Agent, it should be able to join Meldwork.**
|
||||
|
||||
The current MVP starts with fixed adapters for supported local CLIs. The next architecture milestone is a publicly documented Agent connector contract and capability manifest so new local runtimes, cloud Agents, and channel-native Agents can join without being hardcoded into the client.
|
||||
|
||||
| Horizon | Product outcome |
|
||||
| --- | --- |
|
||||
| **Now: local MVP** | Supported local Agent discovery, persistent direct/group conversations, compatible native-session resume, bounded discussions, Skills, images, and explicit local boundaries |
|
||||
| **Next: open connection layer** | Connector contract, capability manifest, structured tasks, comparable runs, artifacts, evidence, and explicit human acceptance |
|
||||
| **Later: after validation** | Cloud-Agent handoff, Meldwork Channels, typed workflows, team policy, cost controls, and enterprise governance |
|
||||
|
||||
These later capabilities are roadmap direction, not shipped behavior.
|
||||
|
||||
## Initial Agent connections
|
||||
|
||||
The current codebase contains adapters and allowlisted installer recipes for:
|
||||
|
||||
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
|
||||
|
||||
These are the first supported connections, not Meldwork's definition of what an Agent can be. An implemented adapter is also not the same as release-certified compatibility; availability depends on the upstream CLI, operating system, authentication, model configuration, and version.
|
||||
|
||||
## A practical workflow
|
||||
|
||||
```text
|
||||
Task -> Runs -> Artifacts -> Evidence -> Human acceptance and adoption
|
||||
Start with one Agent
|
||||
↓
|
||||
Keep the work in one persistent conversation
|
||||
↓
|
||||
Bring in another Agent when a different capability or perspective helps
|
||||
↓
|
||||
Continue in the same workspace instead of rebuilding context
|
||||
↓
|
||||
Inspect the result and decide what to adopt
|
||||
```
|
||||
|
||||
Meldwork should use one Agent by default and add independent attempts or adversarial review only when task risk or uncertainty justifies the extra time and cost. Cloud Agents, Channels, Workflows, and team governance remain roadmap options until this loop demonstrates repeatable value.
|
||||
Use one Agent when one is enough. Add another when the value of a different capability, independent attempt, or adversarial review justifies the extra time and cost.
|
||||
|
||||
## Why Meldwork
|
||||
## Who Meldwork is for
|
||||
|
||||
- **One workspace for different Agents.** Bring Codex, Claude Code, Kimi Code, MiMo Code, and other local CLIs into direct or group conversations.
|
||||
- **Persistent collaborative context.** For compatible CLI versions that expose resumable sessions, continue with one native session per Agent and conversation; resumed prompts add only the group messages that Agent has not already seen.
|
||||
- **Local orchestration with explicit boundaries.** Agent execution, groups, messages, and orchestration stay on the user's machine. Provider traffic still follows each selected Agent's configuration.
|
||||
- **Narrow desktop security boundary.** Executable paths, attachment paths, and native session references stay in the Electron main process. Stored Provider credentials are never returned to the renderer after saving; newly entered credentials cross only the validated Provider IPC. The renderer has no unrestricted shell access.
|
||||
- People who regularly move research, planning, creation, operations, development, or review work between multiple Agents.
|
||||
- AI-native teams whose members use different Agent vendors or specialized capabilities.
|
||||
- Work that benefits from a second perspective without losing the original context.
|
||||
- Users who want to preserve existing Agent accounts, Provider relationships, and a local workspace boundary.
|
||||
|
||||
## Core Capabilities
|
||||
Meldwork is not yet the right choice if you only use one Agent for isolated tasks, need a managed cloud execution queue today, or require audited enterprise controls and release-certified Windows support.
|
||||
|
||||
- Discover installed Agent CLIs without exposing executable paths to the renderer.
|
||||
- Create multiple persistent direct conversations per Agent, then rename or delete each local conversation independently.
|
||||
- Run one response at a time or an automatic discussion with a user-selected 1-10 round limit (6 by default).
|
||||
- Preserve one logical native session per Agent and conversation where the selected CLI supports resume, with automatic migration from older topic-scoped references.
|
||||
- Show per-Agent queued/running/completed state, automatic round progress, background direct-chat completion markers, and local desktop notifications.
|
||||
- Reference validated local Skills with `@` and attach PNG or JPEG images with capability checks.
|
||||
- Request read-only modes by default where an Agent can enforce them, with explicit write opt-in only when the adapter can preserve the workspace boundary. These are adapter-level controls, not an operating-system sandbox.
|
||||
- Install supported missing Agents through fixed, allowlisted recipes. Upstream packages and scripts are not yet digest- or signature-pinned.
|
||||
- Store compatible provider credentials with operating-system-backed secure storage.
|
||||
## Try the MVP locally
|
||||
|
||||
## Supported Agents
|
||||
|
||||
| Agent | Adapter implemented | Installer recipes | Release validation | Provider and permission behavior |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Codex | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Responses-compatible configuration |
|
||||
| Hermes | Yes | macOS and Windows | Pending target-device matrix | Supports a shared OpenAI-compatible Provider |
|
||||
| OpenClaw | Yes | macOS and Windows | Pending target-device matrix | Supports a managed OpenAI-compatible Provider |
|
||||
| WorkBuddy | Yes | macOS and Windows | Pending target-device matrix | Experimental shared Provider support |
|
||||
| Kimi Code | Yes | macOS and Windows | Pending target-device matrix | Uses its native configuration and ACP plan mode for read-only work |
|
||||
| MiMo Code | Yes | macOS and Windows | Pending target-device matrix | Uses native configuration; Meldwork invokes the `plan` Agent and does not enable the global permission-bypass flag |
|
||||
| Claude Code | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Anthropic-compatible configuration |
|
||||
| Gemini CLI | Yes | macOS and Windows | Pending target-device matrix | Uses its existing authentication and Provider settings |
|
||||
| OpenCode | Yes | macOS and Windows | Pending target-device matrix | Uses its existing Provider settings |
|
||||
| Qwen Code | Yes | macOS and Windows | Pending target-device matrix | Supports a shared OpenAI-compatible Provider |
|
||||
|
||||
These entries describe implemented adapters and installer recipes, not release-certified compatibility. Availability still depends on each upstream CLI, operating-system support, authentication, model configuration, and version compatibility. Live-Agent compatibility and real-device Windows validation remain pending.
|
||||
|
||||
## Privacy And Architecture
|
||||
|
||||
```text
|
||||
Vue renderer
|
||||
| validated preload APIs
|
||||
Electron main process
|
||||
|-- Agent discovery and execution
|
||||
|-- local group and message storage
|
||||
|-- local Skill discovery and validation
|
||||
|-- app-owned local image attachment storage and previews
|
||||
|-- provider credential storage
|
||||
`-- user-authorized provider and installer network access
|
||||
```
|
||||
|
||||
The Electron main process owns filesystem paths, child processes, Agent configuration, and persistence. Provider credentials use Electron `safeStorage`; if operating-system encryption is unavailable, Meldwork refuses to persist them in plaintext. Conversations and participants remain local. Network activity occurs only through the selected Agent or Provider, guided installer downloads, or explicit external links.
|
||||
|
||||
Internal compatibility identifiers such as `window.roundrelayDesktop`, existing storage filenames, and `com.roundrelay.desktop` are intentionally retained during the brand transition so current local data and the preload contract continue to work.
|
||||
|
||||
## Development
|
||||
|
||||
Prerequisites: Node.js 20.19 or newer, npm, and macOS for the currently configured release packaging targets.
|
||||
Prerequisites: Node.js 20.19 or newer, npm, and macOS for the currently configured desktop packaging target.
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
|
||||
# Renderer-only preview
|
||||
npm --prefix frontend run dev
|
||||
|
||||
# Electron desktop application
|
||||
# Start the Electron desktop app
|
||||
npm --prefix desktop run dev
|
||||
```
|
||||
|
||||
The browser preview cannot execute Agents because it intentionally has no Electron bridge.
|
||||
For a renderer-only preview:
|
||||
|
||||
## Tests And Builds
|
||||
```bash
|
||||
npm --prefix frontend run dev
|
||||
```
|
||||
|
||||
The browser preview intentionally cannot execute local Agents because it has no Electron bridge.
|
||||
|
||||
<details>
|
||||
<summary><strong>Development checks</strong></summary>
|
||||
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
@@ -105,19 +157,26 @@ npm --prefix desktop run pack
|
||||
|
||||
Generated packages are written to `desktop/dist/` and are not committed.
|
||||
|
||||
## Repository Layout
|
||||
</details>
|
||||
|
||||
```text
|
||||
frontend/ Vue renderer and product assets
|
||||
desktop/ Electron main process, preload bridge, Agent runtime, and tests
|
||||
documentation/ Architecture, security, workflows, and launch material
|
||||
.github/ Continuous integration workflows
|
||||
```
|
||||
<details>
|
||||
<summary><strong>Architecture and security boundaries</strong></summary>
|
||||
|
||||
The architecture review entry point is [`documentation/architecture.md`](documentation/architecture.md). Draft launch messaging lives in [`documentation/launch-kit.md`](documentation/launch-kit.md).
|
||||
The Vue renderer reaches Agent functionality only through validated preload APIs. The Electron main process owns filesystem paths, child processes, Agent configuration, persistence, and secure credential storage. The renderer has no unrestricted shell API.
|
||||
|
||||
## Release Status
|
||||
New conversations currently allow workspace writes by default; users can disable write mode per conversation. Adapter-level read-only modes reduce accidental writes where upstream CLIs can enforce them, but they are not an operating-system sandbox. Guided installers use fixed allowlisted recipes; upstream packages and scripts are not yet digest- or signature-pinned.
|
||||
|
||||
Meldwork is under active development. Local discovery, persistent direct/group conversations, guarded heterogeneous-Agent execution, automatic discussion, `@` Skill references, image attachments, provider storage, frontend tests, desktop tests, and macOS packaging configuration are implemented.
|
||||
Internal identifiers such as `window.roundrelayDesktop`, existing storage filenames, and `com.roundrelay.desktop` are intentionally retained during the brand transition to preserve the current local-data and preload contracts.
|
||||
|
||||
Before a public open-source release, the project still needs a selected license, a target-device Agent compatibility matrix, and signed/notarized macOS distribution. Windows support should remain implementation-level until it passes a real-device release matrix.
|
||||
</details>
|
||||
|
||||
## Project status
|
||||
|
||||
Meldwork is a market-validation MVP, not a finished platform. Before a public release, the project still needs:
|
||||
|
||||
- a selected and committed software license;
|
||||
- target-device compatibility tests for supported Agents;
|
||||
- a public connector contract before "any Agent" is presented as shipped capability;
|
||||
- signed and notarized macOS distribution;
|
||||
- real-device Windows validation before Windows support is marketed as production-ready;
|
||||
- product validation that persistent cross-Agent work creates enough value to justify a new workspace.
|
||||
@@ -521,6 +521,9 @@ class LocalWorkspace extends EventEmitter {
|
||||
const runtimeMissing = runtime?.credentialState === 'missing'
|
||||
const verifiedReady = runtime?.credentialState === 'ready'
|
||||
|| this.state.messages.some(message => message.role === 'agent' && message.agentKind === agent.kind)
|
||||
const nativeReadySource = nativeState === 'ready'
|
||||
? (native.source || 'native-credential')
|
||||
: ''
|
||||
const credentialState = sharedProviderReady
|
||||
? 'ready'
|
||||
: sharedProviderRequired
|
||||
@@ -541,13 +544,13 @@ class LocalWorkspace extends EventEmitter {
|
||||
installed: true,
|
||||
credentialState,
|
||||
availabilitySource: sharedProviderReady
|
||||
? 'shared-provider'
|
||||
? (!runtimeMissing && nativeReadySource ? nativeReadySource : 'shared-provider')
|
||||
: runtimeMissing
|
||||
? 'runtime-auth-failure'
|
||||
: nativeState === 'missing'
|
||||
? (native.source || 'none')
|
||||
: nativeState === 'ready'
|
||||
? (native.source || 'native-credential')
|
||||
: nativeReadySource
|
||||
? nativeReadySource
|
||||
: verifiedReady
|
||||
? 'verified-run'
|
||||
: 'unverified',
|
||||
|
||||
@@ -182,6 +182,9 @@ const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({
|
||||
})
|
||||
|
||||
function providerPresetFromStatus(kind, status = {}) {
|
||||
if (['official', 'openrouter', 'custom'].includes(status.activePreset)) {
|
||||
return status.activePreset
|
||||
}
|
||||
const provider = String(status.provider || '').trim().toLowerCase()
|
||||
const baseUrl = String(status.baseUrl || '').trim().replace(/\/+$/, '')
|
||||
if (/openrouter/i.test(provider) || /openrouter\.ai$/i.test(new URL(baseUrl || 'https://example.com').hostname)) {
|
||||
@@ -288,10 +291,7 @@ function providerOptionsFor(kind, generic, context = {}, status = {}) {
|
||||
function providerOptions(kind, context = {}) {
|
||||
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {}
|
||||
const status = providerStore?.status(kind) || {}
|
||||
if (!status.configured) {
|
||||
if (kind === 'openclaw') throw new Error('PROVIDER_CREDENTIAL_REQUIRED')
|
||||
return {}
|
||||
}
|
||||
if (!status.configured) return {}
|
||||
return providerOptionsFor(
|
||||
kind,
|
||||
providerStore.envForAgent(kind),
|
||||
@@ -577,12 +577,9 @@ function createWorkspace() {
|
||||
importAgentOutputs: input => importAgentOutputs(input, availableAttachmentStore()),
|
||||
validateSkillSelections: (kind, selections) => skillCatalog.validateSelections(kind, selections),
|
||||
imageAttachmentLimit,
|
||||
credentialState: (kind, agent) => {
|
||||
if (kind === 'openclaw' && !providerStore?.status(kind).configured) {
|
||||
return { state: 'missing', source: 'agent-provider-required' }
|
||||
}
|
||||
return resolveNativeCredentialState(kind, { executable: agent?.executable })
|
||||
},
|
||||
credentialState: (kind, agent) => (
|
||||
resolveNativeCredentialState(kind, { executable: agent?.executable })
|
||||
),
|
||||
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
const injected = providerOptions(agent.kind, {
|
||||
@@ -649,7 +646,7 @@ function createWindow() {
|
||||
minWidth: 980,
|
||||
minHeight: 680,
|
||||
title: 'Meldwork',
|
||||
backgroundColor: '#f6f3ed',
|
||||
backgroundColor: '#f3f6f8',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
@@ -799,12 +796,18 @@ function registerIpc() {
|
||||
provider: input?.provider,
|
||||
baseUrl: input?.baseUrl,
|
||||
model: input?.model,
|
||||
preset: input?.preset,
|
||||
})
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
registerTrustedHandle('local-agent-provider:delete', async (kind) => {
|
||||
const result = providerStore.delete(providerAgentKind(kind))
|
||||
registerTrustedHandle('local-agent-provider:activate', async (kind, preset) => {
|
||||
const result = providerStore.activate(providerAgentKind(kind), preset)
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
registerTrustedHandle('local-agent-provider:delete', async (kind, preset) => {
|
||||
const result = providerStore.delete(providerAgentKind(kind), preset)
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -95,7 +95,8 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
status: kind => ipcRenderer.invoke('local-agent-provider:status', kind),
|
||||
probe: kind => ipcRenderer.invoke('local-agent-provider:probe', kind),
|
||||
save: (kind, input) => ipcRenderer.invoke('local-agent-provider:save', kind, input),
|
||||
delete: kind => ipcRenderer.invoke('local-agent-provider:delete', kind),
|
||||
activate: (kind, preset) => ipcRenderer.invoke('local-agent-provider:activate', kind, preset),
|
||||
delete: (kind, preset) => ipcRenderer.invoke('local-agent-provider:delete', kind, preset),
|
||||
}),
|
||||
localKnowledgeBase: Object.freeze({
|
||||
status: () => ipcRenderer.invoke('local-knowledge-base:status'),
|
||||
|
||||
@@ -2,9 +2,11 @@ const fs = require('node:fs')
|
||||
|
||||
const { atomicWritePrivateFile } = require('./private-file.cjs')
|
||||
|
||||
const STORE_VERSION = 2
|
||||
const STORE_VERSION = 3
|
||||
const SINGLE_PROFILE_STORE_VERSION = 2
|
||||
const LEGACY_STORE_VERSION = 1
|
||||
const PROVIDER_KIND = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
const PROVIDER_PRESETS = new Set(['official', 'openrouter', 'custom'])
|
||||
const EMPTY_METADATA = Object.freeze({ provider: '', baseUrl: '', model: '' })
|
||||
|
||||
function normalizeMetadata(input) {
|
||||
@@ -33,6 +35,18 @@ function sortedKeys(value) {
|
||||
: ''
|
||||
}
|
||||
|
||||
function inferStoredPreset(entry) {
|
||||
const provider = String(entry?.provider || '').toLowerCase()
|
||||
const baseUrl = String(entry?.baseUrl || '')
|
||||
return /openrouter/i.test(provider) || /(^|\.)openrouter\.ai$/i.test(hostname(baseUrl))
|
||||
? 'openrouter'
|
||||
: 'custom'
|
||||
}
|
||||
|
||||
function hostname(value) {
|
||||
try { return new URL(value).hostname } catch { return '' }
|
||||
}
|
||||
|
||||
class ProviderStore {
|
||||
constructor({ storagePath, safeStorage, allowedKinds = [] }) {
|
||||
if (typeof storagePath !== 'string' || !storagePath) {
|
||||
@@ -63,6 +77,8 @@ class ProviderStore {
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
encryptionAvailable: null,
|
||||
configured: false,
|
||||
}
|
||||
@@ -71,13 +87,29 @@ class ProviderStore {
|
||||
const encryptionAvailable = this.encryptionAvailable()
|
||||
let metadata = EMPTY_METADATA
|
||||
let configured = false
|
||||
let activePreset = 'official'
|
||||
const profiles = {}
|
||||
if (encryptionAvailable && credentialStored) {
|
||||
try {
|
||||
const entry = this.readDocument().agents[targetKind]
|
||||
if (entry) {
|
||||
this.decryptEntry(entry)
|
||||
metadata = entry
|
||||
configured = true
|
||||
const agent = this.readDocument().agents[targetKind]
|
||||
if (agent) {
|
||||
activePreset = agent.activePreset
|
||||
for (const [preset, entry] of Object.entries(agent.profiles)) {
|
||||
try {
|
||||
this.decryptEntry(entry)
|
||||
profiles[preset] = {
|
||||
provider: entry.provider,
|
||||
baseUrl: entry.baseUrl,
|
||||
model: entry.model,
|
||||
configured: true,
|
||||
}
|
||||
} catch { /* keep an unreadable profile unavailable */ }
|
||||
}
|
||||
const activeEntry = profiles[activePreset]
|
||||
if (activeEntry) {
|
||||
metadata = activeEntry
|
||||
configured = true
|
||||
}
|
||||
}
|
||||
} catch { /* unreadable credentials stay unavailable */ }
|
||||
}
|
||||
@@ -85,6 +117,8 @@ class ProviderStore {
|
||||
provider: metadata.provider,
|
||||
baseUrl: metadata.baseUrl,
|
||||
model: metadata.model,
|
||||
activePreset,
|
||||
profiles,
|
||||
encryptionAvailable,
|
||||
configured,
|
||||
}
|
||||
@@ -92,7 +126,7 @@ class ProviderStore {
|
||||
|
||||
save(kind, input) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
if (sortedKeys(input) !== 'apiKey,baseUrl,model,provider'
|
||||
if (sortedKeys(input) !== 'apiKey,baseUrl,model,preset,provider'
|
||||
|| typeof input.apiKey !== 'string' || !input.apiKey.trim()
|
||||
|| input.apiKey.trim().length > 8192) {
|
||||
throw new Error('PROVIDER_INVALID_CREDENTIAL')
|
||||
@@ -100,6 +134,7 @@ class ProviderStore {
|
||||
this.requireEncryption()
|
||||
|
||||
const apiKey = input.apiKey.trim()
|
||||
const preset = this.normalizePreset(input.preset)
|
||||
const metadata = normalizeMetadata(input)
|
||||
let encrypted
|
||||
try {
|
||||
@@ -115,19 +150,53 @@ class ProviderStore {
|
||||
const document = fs.existsSync(this.storagePath)
|
||||
? this.readDocument()
|
||||
: { version: STORE_VERSION, agents: {} }
|
||||
document.agents[targetKind] = {
|
||||
const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} }
|
||||
agent.profiles[preset] = {
|
||||
...metadata,
|
||||
encrypted: encrypted.toString('base64'),
|
||||
}
|
||||
agent.activePreset = preset
|
||||
document.agents[targetKind] = agent
|
||||
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
|
||||
return this.status(targetKind)
|
||||
}
|
||||
|
||||
delete(kind) {
|
||||
activate(kind, preset) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
const targetPreset = this.normalizePreset(preset)
|
||||
if (!fs.existsSync(this.storagePath)) {
|
||||
if (targetPreset !== 'official') throw new Error('PROVIDER_PROFILE_UNAVAILABLE')
|
||||
return this.status(targetKind, { probeEncryption: true })
|
||||
}
|
||||
this.requireEncryption()
|
||||
const document = this.readDocument()
|
||||
const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} }
|
||||
if (targetPreset !== 'official' && !agent.profiles[targetPreset]) {
|
||||
throw new Error('PROVIDER_PROFILE_UNAVAILABLE')
|
||||
}
|
||||
if (agent.profiles[targetPreset]) this.decryptEntry(agent.profiles[targetPreset])
|
||||
agent.activePreset = targetPreset
|
||||
document.agents[targetKind] = agent
|
||||
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
|
||||
return this.status(targetKind)
|
||||
}
|
||||
|
||||
delete(kind, preset = '') {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
if (fs.existsSync(this.storagePath)) {
|
||||
const document = this.readDocument()
|
||||
delete document.agents[targetKind]
|
||||
if (preset) {
|
||||
const targetPreset = this.normalizePreset(preset)
|
||||
const agent = document.agents[targetKind]
|
||||
if (agent) {
|
||||
delete agent.profiles[targetPreset]
|
||||
if (agent.activePreset === targetPreset) agent.activePreset = 'official'
|
||||
if (Object.keys(agent.profiles).length) document.agents[targetKind] = agent
|
||||
else delete document.agents[targetKind]
|
||||
}
|
||||
} else {
|
||||
delete document.agents[targetKind]
|
||||
}
|
||||
if (Object.keys(document.agents).length) {
|
||||
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
|
||||
} else {
|
||||
@@ -140,7 +209,8 @@ class ProviderStore {
|
||||
envForAgent(kind) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
this.requireEncryption()
|
||||
const entry = this.readDocument().agents[targetKind]
|
||||
const agent = this.readDocument().agents[targetKind]
|
||||
const entry = agent?.profiles?.[agent.activePreset]
|
||||
if (!entry) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
return {
|
||||
OPENAI_API_KEY: this.decryptEntry(entry),
|
||||
@@ -161,6 +231,12 @@ class ProviderStore {
|
||||
return normalized
|
||||
}
|
||||
|
||||
normalizePreset(preset) {
|
||||
const normalized = String(preset || '').trim().toLowerCase()
|
||||
if (!PROVIDER_PRESETS.has(normalized)) throw new Error('PROVIDER_PRESET_UNSUPPORTED')
|
||||
return normalized
|
||||
}
|
||||
|
||||
decryptEntry(entry) {
|
||||
this.requireEncryption()
|
||||
try {
|
||||
@@ -194,11 +270,30 @@ class ProviderStore {
|
||||
model: payload.model,
|
||||
encrypted: payload.encrypted,
|
||||
})
|
||||
const preset = inferStoredPreset(entry)
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, { ...entry }])),
|
||||
agents: Object.fromEntries([...this.allowedKinds].map(kind => [kind, {
|
||||
activePreset: preset,
|
||||
profiles: { [preset]: { ...entry } },
|
||||
}])),
|
||||
}
|
||||
}
|
||||
if (payload?.version === SINGLE_PROFILE_STORE_VERSION) {
|
||||
if (sortedKeys(payload) !== 'agents,version'
|
||||
|| !payload.agents || typeof payload.agents !== 'object'
|
||||
|| Array.isArray(payload.agents)) {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
const agents = {}
|
||||
for (const [kind, rawEntry] of Object.entries(payload.agents)) {
|
||||
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
const entry = this.normalizeEntry(rawEntry)
|
||||
const preset = inferStoredPreset(entry)
|
||||
agents[kind] = { activePreset: preset, profiles: { [preset]: entry } }
|
||||
}
|
||||
return { version: STORE_VERSION, agents }
|
||||
}
|
||||
if (sortedKeys(payload) !== 'agents,version'
|
||||
|| payload.version !== STORE_VERSION
|
||||
|| !payload.agents || typeof payload.agents !== 'object'
|
||||
@@ -208,11 +303,33 @@ class ProviderStore {
|
||||
const agents = {}
|
||||
for (const [kind, entry] of Object.entries(payload.agents)) {
|
||||
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
agents[kind] = this.normalizeEntry(entry)
|
||||
agents[kind] = this.normalizeAgentEntry(entry)
|
||||
}
|
||||
return { version: STORE_VERSION, agents }
|
||||
}
|
||||
|
||||
normalizeAgentEntry(entry) {
|
||||
try {
|
||||
if (sortedKeys(entry) !== 'activePreset,profiles'
|
||||
|| !entry.profiles || typeof entry.profiles !== 'object'
|
||||
|| Array.isArray(entry.profiles)) {
|
||||
throw new Error('invalid agent entry')
|
||||
}
|
||||
const activePreset = this.normalizePreset(entry.activePreset)
|
||||
const profiles = {}
|
||||
for (const [preset, profile] of Object.entries(entry.profiles)) {
|
||||
const normalizedPreset = this.normalizePreset(preset)
|
||||
profiles[normalizedPreset] = this.normalizeEntry(profile)
|
||||
}
|
||||
if (activePreset !== 'official' && !profiles[activePreset]) {
|
||||
throw new Error('missing active profile')
|
||||
}
|
||||
return { activePreset, profiles }
|
||||
} catch {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
normalizeEntry(entry) {
|
||||
if (sortedKeys(entry) !== 'baseUrl,encrypted,model,provider'
|
||||
|| typeof entry.encrypted !== 'string') {
|
||||
|
||||
@@ -85,6 +85,30 @@ test('installed Agents distinguish ready, unverified, and missing credential sta
|
||||
assert.equal(readinessAgents.find(agent => agent.kind === 'kimi').executable, '/tmp/kimi')
|
||||
})
|
||||
|
||||
test('native readiness remains visible while a Meldwork Provider profile is active', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
let codexNativeState = { state: 'ready', source: 'native-auth-status' }
|
||||
options.credentialState = async kind => kind === 'codex'
|
||||
? codexNativeState
|
||||
: { state: 'ready', source: 'native-credential' }
|
||||
options.sharedProviderReady = kind => kind === 'codex'
|
||||
const workspace = new LocalWorkspace(options)
|
||||
|
||||
let snapshot = await workspace.refreshAgents()
|
||||
let codex = snapshot.agents.find(agent => agent.kind === 'codex')
|
||||
assert.equal(codex.credentialState, 'ready')
|
||||
assert.equal(codex.availabilitySource, 'native-auth-status')
|
||||
assert.equal(codex.available, true)
|
||||
|
||||
codexNativeState = { state: 'missing', source: 'native-auth-status' }
|
||||
snapshot = await workspace.refreshAgents()
|
||||
codex = snapshot.agents.find(agent => agent.kind === 'codex')
|
||||
assert.equal(codex.credentialState, 'ready')
|
||||
assert.equal(codex.availabilitySource, 'shared-provider')
|
||||
assert.equal(codex.available, true)
|
||||
})
|
||||
|
||||
test('an unverified installed Agent stays unavailable until credentials are detected', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -50,6 +50,7 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-agent-provider:status',
|
||||
'local-agent-provider:probe',
|
||||
'local-agent-provider:save',
|
||||
'local-agent-provider:activate',
|
||||
'local-agent-provider:delete',
|
||||
'local-knowledge-base:status',
|
||||
'local-knowledge-base:open-guide',
|
||||
@@ -104,6 +105,7 @@ function loadMain(userData, options = {}) {
|
||||
let singleInstanceLockCalls = 0
|
||||
let attachmentConstructionCount = 0
|
||||
const providerConfiguredKinds = new Set(options.providerConfigured === true ? ['hermes'] : [])
|
||||
const providerActivePresets = new Map(options.providerConfigured === true ? [['hermes', 'custom']] : [])
|
||||
|
||||
class TestWorkspace extends EventEmitter {
|
||||
constructor(input) {
|
||||
@@ -278,6 +280,7 @@ function loadMain(userData, options = {}) {
|
||||
constructor(input) {
|
||||
this.input = input
|
||||
this.saved = []
|
||||
this.activated = []
|
||||
this.deleted = []
|
||||
this.statusCalls = []
|
||||
providerInstances.push(this)
|
||||
@@ -286,22 +289,40 @@ function loadMain(userData, options = {}) {
|
||||
status(kind, input) {
|
||||
this.statusCalls.push({ kind, input })
|
||||
return providerConfiguredKinds.has(kind)
|
||||
? { ...PROVIDER_METADATA, encryptionAvailable: true, configured: true }
|
||||
? {
|
||||
...PROVIDER_METADATA,
|
||||
activePreset: providerActivePresets.get(kind) || 'custom',
|
||||
profiles: {
|
||||
[providerActivePresets.get(kind) || 'custom']: { ...PROVIDER_METADATA, configured: true },
|
||||
},
|
||||
encryptionAvailable: true,
|
||||
configured: true,
|
||||
}
|
||||
: {
|
||||
provider: '', baseUrl: '', model: '',
|
||||
encryptionAvailable: true, configured: false,
|
||||
activePreset: 'official', profiles: {}, encryptionAvailable: true, configured: false,
|
||||
}
|
||||
}
|
||||
|
||||
save(kind, input) {
|
||||
this.saved.push({ kind, input })
|
||||
providerConfiguredKinds.add(kind)
|
||||
providerActivePresets.set(kind, input.preset)
|
||||
return this.status(kind)
|
||||
}
|
||||
|
||||
delete(kind) {
|
||||
this.deleted.push(kind)
|
||||
activate(kind, preset) {
|
||||
this.activated.push({ kind, preset })
|
||||
providerActivePresets.set(kind, preset)
|
||||
if (preset === 'official') providerConfiguredKinds.delete(kind)
|
||||
else providerConfiguredKinds.add(kind)
|
||||
return this.status(kind)
|
||||
}
|
||||
|
||||
delete(kind, preset) {
|
||||
this.deleted.push({ kind, preset })
|
||||
providerConfiguredKinds.delete(kind)
|
||||
providerActivePresets.set(kind, 'official')
|
||||
return this.status(kind)
|
||||
}
|
||||
|
||||
@@ -955,7 +976,7 @@ test('Provider IPC accepts the complete user payload and supports local deletion
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const { harness } = loadMain(directory)
|
||||
await harness.ready()
|
||||
const input = { ...PROVIDER_METADATA, apiKey: 'renderer-provider-key' }
|
||||
const input = { ...PROVIDER_METADATA, apiKey: 'renderer-provider-key', preset: 'custom' }
|
||||
|
||||
const initial = await harness.ipcHandlers.get('local-agent-provider:status')(
|
||||
harness.event(), 'hermes',
|
||||
@@ -976,17 +997,24 @@ test('Provider IPC accepts the complete user payload and supports local deletion
|
||||
assert.equal(saved.configured, true)
|
||||
assert.equal(harness.workspaceInstances[0].refreshCount, 1)
|
||||
|
||||
const activated = await harness.ipcHandlers.get('local-agent-provider:activate')(
|
||||
harness.event(), 'hermes', 'custom',
|
||||
)
|
||||
assert.deepEqual(harness.providerInstances[0].activated, [{ kind: 'hermes', preset: 'custom' }])
|
||||
assert.equal(activated.activePreset, 'custom')
|
||||
assert.equal(harness.workspaceInstances[0].refreshCount, 2)
|
||||
|
||||
await assert.rejects(
|
||||
async () => harness.ipcHandlers.get('local-agent-provider:status')(harness.event(), 'not-an-agent'),
|
||||
{ message: 'PROVIDER_AGENT_UNSUPPORTED' },
|
||||
)
|
||||
|
||||
const deleted = await harness.ipcHandlers.get('local-agent-provider:delete')(
|
||||
harness.event(), 'hermes',
|
||||
harness.event(), 'hermes', 'custom',
|
||||
)
|
||||
assert.deepEqual(harness.providerInstances[0].deleted, ['hermes'])
|
||||
assert.deepEqual(harness.providerInstances[0].deleted, [{ kind: 'hermes', preset: 'custom' }])
|
||||
assert.equal(deleted.configured, false)
|
||||
assert.equal(harness.workspaceInstances[0].refreshCount, 2)
|
||||
assert.equal(harness.workspaceInstances[0].refreshCount, 3)
|
||||
})
|
||||
|
||||
test('Knowledge base IPC exposes status, safe guides, and a local Obsidian directory picker', async (t) => {
|
||||
@@ -1261,6 +1289,44 @@ test('configured Provider is injected only through local Agent execution options
|
||||
assert.equal(options.env.CURRENT_RUN, '1')
|
||||
})
|
||||
|
||||
test('OpenClaw keeps native readiness and execution when no Meldwork Provider profile is active', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-openclaw-native-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const credentialChecks = []
|
||||
const { harness } = loadMain(directory, {
|
||||
moduleMocks: {
|
||||
'./local-agent-readiness.cjs': {
|
||||
nativeCredentialEnvironment: kind => kind === 'openclaw'
|
||||
? { OPENCLAW_NATIVE_CONFIG: 'available' }
|
||||
: {},
|
||||
resolveNativeCredentialState: async (kind, input) => {
|
||||
credentialChecks.push({ kind, input })
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await harness.ready()
|
||||
const workspace = harness.workspaceInstances[0]
|
||||
|
||||
const readiness = await workspace.input.credentialState('openclaw', {
|
||||
executable: '/tmp/openclaw',
|
||||
})
|
||||
assert.deepEqual(readiness, { state: 'ready', source: 'native-credential' })
|
||||
assert.deepEqual(credentialChecks, [{
|
||||
kind: 'openclaw',
|
||||
input: { executable: '/tmp/openclaw' },
|
||||
}])
|
||||
|
||||
await workspace.input.runAgent(
|
||||
{ kind: 'openclaw', executable: '/tmp/openclaw' },
|
||||
'hello', directory, { sessionRef: 'agent:main:desktop-native-openclaw' },
|
||||
)
|
||||
const options = harness.runAgentCalls[0][3]
|
||||
assert.equal(options.env.OPENCLAW_NATIVE_CONFIG, 'available')
|
||||
assert.equal(Object.hasOwn(options.env, 'MANAGED_OPENCLAW'), false)
|
||||
})
|
||||
|
||||
test('manual Agent refreshes remain serialized', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'roundrelay-refresh-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -70,7 +70,7 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me
|
||||
assert.equal('cancelConfigure' in api.localWorkspace, false)
|
||||
assert.deepEqual(
|
||||
Object.keys(api.localAgentProvider).sort(),
|
||||
['delete', 'probe', 'save', 'status'],
|
||||
['activate', 'delete', 'probe', 'save', 'status'],
|
||||
)
|
||||
assert.equal('envForAgent' in api.localAgentProvider, false)
|
||||
assert.equal('read' in api.localAgentProvider, false)
|
||||
@@ -79,17 +79,19 @@ test('local preload exposes the local-only RoundRelay API and narrow Provider me
|
||||
await api.localAgentProvider.probe('hermes')
|
||||
await api.localAgentProvider.save('hermes', {
|
||||
provider: 'Example', baseUrl: 'https://api.example.com/v1',
|
||||
model: 'example-model', apiKey: 'test-renderer-key',
|
||||
model: 'example-model', apiKey: 'test-renderer-key', preset: 'custom',
|
||||
})
|
||||
await api.localAgentProvider.delete('hermes')
|
||||
await api.localAgentProvider.activate('hermes', 'custom')
|
||||
await api.localAgentProvider.delete('hermes', 'custom')
|
||||
assert.deepEqual(invocations.map(call => call.channel), [
|
||||
'local-agent-provider:status',
|
||||
'local-agent-provider:probe',
|
||||
'local-agent-provider:save',
|
||||
'local-agent-provider:activate',
|
||||
'local-agent-provider:delete',
|
||||
])
|
||||
assert.deepEqual(invocations.map(call => call.args[0]), [
|
||||
'hermes', 'hermes', 'hermes', 'hermes',
|
||||
'hermes', 'hermes', 'hermes', 'hermes', 'hermes',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -10,9 +10,31 @@ const PROVIDER = Object.freeze({
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
model: 'example-model',
|
||||
})
|
||||
const OPENROUTER_PROVIDER = Object.freeze({
|
||||
provider: 'OpenRouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'openrouter/example-model',
|
||||
})
|
||||
|
||||
function credential(apiKey = 'test-provider-key', metadata = PROVIDER) {
|
||||
return { ...metadata, apiKey }
|
||||
function credential(apiKey = 'test-provider-key', metadata = PROVIDER, preset = 'custom') {
|
||||
return { ...metadata, apiKey, preset }
|
||||
}
|
||||
|
||||
function emptyStatus(encryptionAvailable) {
|
||||
return {
|
||||
provider: '', baseUrl: '', model: '', activePreset: 'official', profiles: {},
|
||||
encryptionAvailable, configured: false,
|
||||
}
|
||||
}
|
||||
|
||||
function configuredStatus(metadata = PROVIDER, preset = 'custom') {
|
||||
return {
|
||||
...metadata,
|
||||
activePreset: preset,
|
||||
profiles: { [preset]: { ...metadata, configured: true } },
|
||||
encryptionAvailable: true,
|
||||
configured: true,
|
||||
}
|
||||
}
|
||||
|
||||
function encryptedSafeStorage(available = true) {
|
||||
@@ -37,13 +59,11 @@ test('status exposes user-configured metadata without the API key', (t) => {
|
||||
const { directory, store } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
assert.deepEqual(store.status('hermes'), {
|
||||
provider: '', baseUrl: '', model: '', encryptionAvailable: null, configured: false,
|
||||
})
|
||||
assert.deepEqual(store.status('hermes'), emptyStatus(null))
|
||||
|
||||
store.save('hermes', credential())
|
||||
const status = store.status('hermes')
|
||||
assert.deepEqual(status, { ...PROVIDER, encryptionAvailable: true, configured: true })
|
||||
assert.deepEqual(status, configuredStatus())
|
||||
assert.equal('apiKey' in status, false)
|
||||
})
|
||||
|
||||
@@ -68,9 +88,7 @@ test('save encrypts the API key, writes atomically, and restricts file permissio
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const apiKey = 'test-key-never-plaintext'
|
||||
|
||||
assert.deepEqual(store.save('hermes', credential(apiKey)), {
|
||||
...PROVIDER, encryptionAvailable: true, configured: true,
|
||||
})
|
||||
assert.deepEqual(store.save('hermes', credential(apiKey)), configuredStatus())
|
||||
const stored = fs.readFileSync(storagePath, 'utf8')
|
||||
assert.equal(stored.includes(apiKey), false)
|
||||
assert.equal(fs.statSync(storagePath).mode & 0o777, 0o600)
|
||||
@@ -80,7 +98,7 @@ test('save encrypts the API key, writes atomically, and restricts file permissio
|
||||
)
|
||||
})
|
||||
|
||||
test('save accepts only the complete four-field Provider payload', (t) => {
|
||||
test('save accepts only the complete five-field Provider payload', (t) => {
|
||||
const { directory, store } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -91,9 +109,15 @@ test('save accepts only the complete four-field Provider payload', (t) => {
|
||||
{ message: 'PROVIDER_INVALID_CREDENTIAL' },
|
||||
)
|
||||
assert.throws(
|
||||
() => store.save('hermes', { apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model' }),
|
||||
() => store.save('hermes', {
|
||||
apiKey: 'key', provider: 'Example', baseUrl: '', model: 'model', preset: 'custom',
|
||||
}),
|
||||
{ message: 'PROVIDER_INVALID_METADATA' },
|
||||
)
|
||||
assert.throws(
|
||||
() => store.save('hermes', credential('key', PROVIDER, 'unknown')),
|
||||
{ message: 'PROVIDER_PRESET_UNSUPPORTED' },
|
||||
)
|
||||
})
|
||||
|
||||
test('envForAgent decrypts the configured Provider', (t) => {
|
||||
@@ -108,13 +132,47 @@ test('envForAgent decrypts the configured Provider', (t) => {
|
||||
})
|
||||
})
|
||||
|
||||
test('multiple Provider profiles stay encrypted and can be activated independently', (t) => {
|
||||
const { directory, store } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
store.save('hermes', credential('openrouter-key', OPENROUTER_PROVIDER, 'openrouter'))
|
||||
store.save('hermes', credential('custom-key', PROVIDER, 'custom'))
|
||||
|
||||
assert.deepEqual(store.status('hermes'), {
|
||||
...PROVIDER,
|
||||
activePreset: 'custom',
|
||||
profiles: {
|
||||
openrouter: { ...OPENROUTER_PROVIDER, configured: true },
|
||||
custom: { ...PROVIDER, configured: true },
|
||||
},
|
||||
encryptionAvailable: true,
|
||||
configured: true,
|
||||
})
|
||||
|
||||
store.activate('hermes', 'openrouter')
|
||||
assert.equal(store.status('hermes').activePreset, 'openrouter')
|
||||
assert.deepEqual(store.envForAgent('hermes'), {
|
||||
OPENAI_API_KEY: 'openrouter-key',
|
||||
OPENAI_BASE_URL: OPENROUTER_PROVIDER.baseUrl,
|
||||
OPENAI_MODEL: OPENROUTER_PROVIDER.model,
|
||||
})
|
||||
|
||||
const afterDelete = store.delete('hermes', 'openrouter')
|
||||
assert.equal(afterDelete.activePreset, 'official')
|
||||
assert.equal(afterDelete.configured, false)
|
||||
assert.deepEqual(afterDelete.profiles, {
|
||||
custom: { ...PROVIDER, configured: true },
|
||||
})
|
||||
store.activate('hermes', 'custom')
|
||||
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'custom-key')
|
||||
})
|
||||
|
||||
test('encryption unavailability fails closed for status, save, and reads', (t) => {
|
||||
const { directory, storagePath, store } = fixture(encryptedSafeStorage(false))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
assert.deepEqual(store.status('hermes', { probeEncryption: true }), {
|
||||
provider: '', baseUrl: '', model: '', encryptionAvailable: false, configured: false,
|
||||
})
|
||||
assert.deepEqual(store.status('hermes', { probeEncryption: true }), emptyStatus(false))
|
||||
assert.throws(() => store.save('hermes', credential()), {
|
||||
message: 'PROVIDER_ENCRYPTION_UNAVAILABLE',
|
||||
})
|
||||
@@ -145,9 +203,7 @@ test('corrupt credentials stay unavailable and are never returned', (t) => {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
assert.deepEqual(store.status('hermes'), {
|
||||
provider: '', baseUrl: '', model: '', encryptionAvailable: true, configured: false,
|
||||
})
|
||||
assert.deepEqual(store.status('hermes'), emptyStatus(true))
|
||||
assert.throws(() => store.envForAgent('hermes'), {
|
||||
message: 'PROVIDER_CREDENTIAL_UNAVAILABLE',
|
||||
})
|
||||
@@ -160,9 +216,7 @@ test('delete removes the local credential even when encryption is unavailable',
|
||||
store.save('hermes', credential())
|
||||
safeStorage.isEncryptionAvailable = () => false
|
||||
|
||||
assert.deepEqual(store.delete('hermes'), {
|
||||
provider: '', baseUrl: '', model: '', encryptionAvailable: false, configured: false,
|
||||
})
|
||||
assert.deepEqual(store.delete('hermes'), emptyStatus(false))
|
||||
assert.equal(fs.existsSync(storagePath), false)
|
||||
assert.doesNotThrow(() => store.delete('hermes'))
|
||||
})
|
||||
@@ -216,12 +270,48 @@ test('legacy shared credentials migrate into independent Agent entries on mutati
|
||||
|
||||
store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'new-qwen-model' }))
|
||||
const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8'))
|
||||
assert.equal(migrated.version, 2)
|
||||
assert.equal(migrated.version, 3)
|
||||
assert.deepEqual(Object.keys(migrated.agents).sort(), ['hermes', 'qwen'])
|
||||
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'legacy-key')
|
||||
assert.equal(store.envForAgent('qwen').OPENAI_API_KEY, 'new-qwen-key')
|
||||
})
|
||||
|
||||
test('single-profile Agent credentials migrate to multi-profile storage on mutation', (t) => {
|
||||
const { directory, storagePath, store } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const encrypted = encryptedSafeStorage().encryptString('stored-openrouter-key').toString('base64')
|
||||
fs.writeFileSync(storagePath, JSON.stringify({
|
||||
version: 2,
|
||||
agents: {
|
||||
hermes: { ...OPENROUTER_PROVIDER, encrypted },
|
||||
},
|
||||
}), { mode: 0o600 })
|
||||
|
||||
assert.equal(store.status('hermes').activePreset, 'openrouter')
|
||||
assert.equal(store.envForAgent('hermes').OPENAI_API_KEY, 'stored-openrouter-key')
|
||||
|
||||
store.save('qwen', credential('new-qwen-key', { ...PROVIDER, model: 'qwen-model' }))
|
||||
const migrated = JSON.parse(fs.readFileSync(storagePath, 'utf8'))
|
||||
assert.equal(migrated.version, 3)
|
||||
assert.equal(migrated.agents.hermes.activePreset, 'openrouter')
|
||||
assert.equal(migrated.agents.qwen.activePreset, 'custom')
|
||||
})
|
||||
|
||||
test('malformed multi-profile documents fail as unavailable credentials', (t) => {
|
||||
const { directory, storagePath, store } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
fs.writeFileSync(storagePath, JSON.stringify({
|
||||
version: 3,
|
||||
agents: {
|
||||
hermes: { activePreset: 'unknown', profiles: {} },
|
||||
},
|
||||
}), { mode: 0o600 })
|
||||
|
||||
assert.throws(() => store.envForAgent('hermes'), {
|
||||
message: 'PROVIDER_CREDENTIAL_UNAVAILABLE',
|
||||
})
|
||||
})
|
||||
|
||||
test('Provider operations reject unsupported Agent kinds', (t) => {
|
||||
const { directory, store } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="DingTalk">
|
||||
<!-- Source: https://www.dingtalk.com/ -->
|
||||
<title>DingTalk</title>
|
||||
<path fill="#1185FE" d="M573.7 252.5C422.5 197.4 201.3 96.7 201.3 96.7c-15.7-4.1-17.9 11.1-17.9 11.1-5 61.1 33.6 160.5 53.6 182.8 19.9 22.3 319.1 113.7 319.1 113.7S326 357.9 270.5 341.9c-55.6-16-37.9 17.8-37.9 17.8 11.4 61.7 64.9 131.8 107.2 138.4 42.2 6.6 220.1 4 220.1 4s-35.5 4.1-93.2 11.9c-42.7 5.8-97 12.5-111.1 17.8-33.1 12.5 24 62.6 24 62.6 84.7 76.8 129.7 50.5 129.7 50.5 33.3-10.7 61.4-18.5 85.2-24.2L565 743.1h84.6L603 928l205.3-271.9H700.8l22.3-38.7c.3.5.4.8.4.8S799.8 496.1 829 433.8l.6-1h-.1c5-10.8 8.6-19.7 10-25.8 17-71.3-114.5-99.4-265.8-154.5z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 697 B After Width: | Height: | Size: 742 B |
@@ -1,4 +1,5 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Feishu">
|
||||
<!-- Source: https://open.feishu.cn/ -->
|
||||
<title>Feishu</title>
|
||||
<path d="M8.5611 8.26287L8.59322 8.23075C8.6141 8.20988 8.63658 8.18739 8.65906 8.16652L8.70402 8.12316L8.8373 7.99148L9.02037 7.81324L9.17613 7.65908L9.32226 7.51456L9.47481 7.36361L9.61452 7.22551L9.81043 7.03281C9.84737 6.99588 9.8859 6.96055 9.92444 6.92522C9.9951 6.86099 10.069 6.79836 10.1428 6.73734C10.2119 6.68274 10.2825 6.62975 10.3548 6.57836C10.456 6.5061 10.5603 6.44026 10.6663 6.37603C10.7707 6.31501 10.8783 6.2572 10.9875 6.2026C11.0903 6.15282 11.1963 6.10625 11.3038 6.0645C11.3633 6.04041 11.4243 6.01954 11.4853 5.99866C11.5158 5.98903 11.5463 5.97779 11.5784 5.96815C11.3071 4.90028 10.8109 3.90468 10.122 3.04556C9.98868 2.88016 9.78634 2.78381 9.57438 2.78381H3.94598C3.88817 2.78381 3.84 2.83038 3.84 2.8898C3.84 2.92352 3.85605 2.95403 3.88335 2.97491C5.80391 4.38321 7.39689 6.19297 8.54826 8.27732L8.5611 8.26287Z" fill="#00D6B9"/>
|
||||
<path d="M6.32424 13.2168C9.23077 13.2168 11.7631 11.6126 13.0831 9.24238C13.1297 9.15887 13.1747 9.07537 13.218 8.99026C13.1522 9.11712 13.0783 9.23917 12.9964 9.35478C12.9675 9.39493 12.9386 9.43508 12.9081 9.47522C12.8696 9.525 12.831 9.57157 12.7909 9.61814C12.7588 9.65507 12.7266 9.6904 12.6929 9.72573C12.6255 9.79638 12.5548 9.86383 12.4809 9.92646C12.4392 9.96178 12.3991 9.99551 12.3557 10.0276C12.3059 10.0662 12.2545 10.1031 12.2031 10.1368C12.171 10.1593 12.1373 10.1802 12.1036 10.2011C12.0699 10.2219 12.0345 10.2428 11.9976 10.2637C11.9253 10.3038 11.8499 10.3424 11.7744 10.3761C11.7085 10.405 11.6411 10.4323 11.5737 10.458C11.4998 10.4853 11.4259 10.5094 11.3488 10.5302C11.2348 10.5624 11.1208 10.5864 11.0036 10.6041C10.9201 10.617 10.8334 10.6266 10.7483 10.633C10.6583 10.6394 10.5668 10.641 10.4753 10.641C10.3741 10.6394 10.2729 10.633 10.1702 10.6218C10.0947 10.6137 10.0192 10.6025 9.94375 10.5897C9.87791 10.5784 9.81208 10.564 9.74624 10.5479C9.71091 10.5399 9.67719 10.5302 9.64186 10.5206C9.54551 10.4949 9.44916 10.4676 9.35281 10.4403C9.30464 10.4259 9.25646 10.413 9.20989 10.3986C9.13763 10.3777 9.06698 10.3552 8.99632 10.3327C8.93851 10.3151 8.8807 10.2958 8.82289 10.2765C8.76829 10.2589 8.71209 10.2412 8.65749 10.2219L8.54508 10.1834C8.50012 10.1673 8.45355 10.1513 8.40859 10.1352L8.31224 10.0999C8.24801 10.0774 8.18378 10.0533 8.12115 10.0292C8.08421 10.0148 8.04728 10.0019 8.01035 9.98748C7.96057 9.96821 7.91239 9.94894 7.86261 9.92967C7.81123 9.90879 7.75823 9.88792 7.70685 9.86704L7.60568 9.82529L7.48043 9.7739L7.38408 9.73376L7.28452 9.6904L7.1978 9.65186L7.11912 9.61653L7.03883 9.5796L6.95693 9.54106L6.85255 9.49288L6.74336 9.4415C6.70482 9.42223 6.66628 9.40456 6.62774 9.38529L6.52978 9.33712C4.80192 8.4748 3.24267 7.31218 1.92269 5.90227C1.88254 5.86052 1.8167 5.85731 1.77335 5.89746C1.75247 5.91673 1.73962 5.94563 1.73962 5.97454L1.74284 10.9413V11.3444C1.74284 11.5788 1.85845 11.7972 2.05276 11.9273C3.31654 12.772 4.80353 13.22 6.32424 13.2168Z" fill="#3370FF"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -1,4 +1,5 @@
|
||||
<svg fill="none" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Obsidian">
|
||||
<!-- Source: https://obsidian.md/brand -->
|
||||
<title>Obsidian</title>
|
||||
<radialGradient id="logo-bottom-left" cx="0" cy="0" gradientTransform="matrix(-59 -225 150 -39 161.4 470)" gradientUnits="userSpaceOnUse" r="1">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".4"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none" role="img" aria-labelledby="title">
|
||||
<title id="title">Meldwork app icon</title>
|
||||
<rect x="4" y="4" width="120" height="120" rx="24" fill="#0E0F11" />
|
||||
<g transform="translate(16 16) scale(1.5)">
|
||||
<path
|
||||
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="8.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 620 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Trace app icon</title>
|
||||
<desc id="desc">A white asymmetric work line ending before a separate coral acceptance point on a carbon background.</desc>
|
||||
<rect x="48" y="48" width="928" height="928" rx="220" fill="#0E1013" />
|
||||
<g transform="translate(78 190) scale(9)">
|
||||
<path
|
||||
d="M13 57V33C13 23.4 18.3 17 26.2 17C34.1 17 38.8 23.4 38.8 32.8V56.5C38.8 39.1 44.4 27 52.9 27C61.1 27 65.9 33.5 65.9 42.5V47.5C65.9 53.8 69.1 57 74.7 57"
|
||||
stroke="#F7F8FA"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="85.5" cy="57" r="4.8" fill="#EF5A45" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 768 B |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 44 KiB |
@@ -1,18 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||
<rect x="48" y="48" width="928" height="928" rx="220" fill="#18212B"/>
|
||||
<path
|
||||
d="M214 742V409C214 286 282 207 373 207C464 207 525 282 525 402V728"
|
||||
stroke="#F7F8FA"
|
||||
stroke-width="120"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M525 505C525 385 590 303 684 303C778 303 836 378 836 497V562C836 650 869 699 937 728"
|
||||
stroke="#F7F8FA"
|
||||
stroke-width="120"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M895 711C913 718 927 723 940 728" stroke="#FF7A61" stroke-width="120" stroke-linecap="round"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Trace app icon</title>
|
||||
<desc id="desc">A white asymmetric work line ending before a separate coral acceptance point on a carbon background.</desc>
|
||||
<rect x="48" y="48" width="928" height="928" rx="220" fill="#0E1013" />
|
||||
<g transform="translate(78 190) scale(9)">
|
||||
<path
|
||||
d="M13 57V33C13 23.4 18.3 17 26.2 17C34.1 17 38.8 23.4 38.8 32.8V56.5C38.8 39.1 44.4 27 52.9 27C61.1 27 65.9 33.5 65.9 42.5V47.5C65.9 53.8 69.1 57 74.7 57"
|
||||
stroke="#F7F8FA"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="85.5" cy="57" r="4.8" fill="#EF5A45" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 768 B |
@@ -1,49 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Open Stitch brand board</title>
|
||||
<desc id="desc">A five-panel preview of the Meldwork V2 mark, wordmark, app icon, construction, color system, and slogan.</desc>
|
||||
<rect width="1600" height="1000" fill="#E9EBEE" />
|
||||
|
||||
<rect x="60" y="60" width="700" height="410" rx="8" fill="#FFFFFF" />
|
||||
<g transform="translate(170 160) scale(3.15)">
|
||||
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#15171A" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</g>
|
||||
<text x="400" y="298" fill="#15171A" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="58" font-weight="650">Meldwork</text>
|
||||
<text x="402" y="342" fill="#6E7278" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">OPEN STITCH / V2 CANDIDATE</text>
|
||||
|
||||
<rect x="800" y="60" width="740" height="410" rx="8" fill="#0E0F11" />
|
||||
<g transform="translate(880 162) scale(3.15)">
|
||||
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#FFFFFF" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</g>
|
||||
<text x="1115" y="278" fill="#FFFFFF" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="38" font-weight="650">Different agents.</text>
|
||||
<text x="1115" y="334" fill="#FFFFFF" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="38" font-weight="650">Work you can verify.</text>
|
||||
|
||||
<rect x="60" y="510" width="450" height="430" rx="8" fill="#F7F8F9" />
|
||||
<rect x="178" y="600" width="214" height="214" rx="42" fill="#0E0F11" />
|
||||
<g transform="translate(205 627) scale(2.5)">
|
||||
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#FFFFFF" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</g>
|
||||
<text x="285" y="865" text-anchor="middle" fill="#15171A" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="20" font-weight="600">APP ICON</text>
|
||||
|
||||
<rect x="550" y="510" width="450" height="430" rx="8" fill="#FFFFFF" />
|
||||
<line x1="625" y1="725" x2="925" y2="725" stroke="#D6D9DD" stroke-width="2" stroke-dasharray="7 9" />
|
||||
<line x1="775" y1="585" x2="775" y2="855" stroke="#D6D9DD" stroke-width="2" stroke-dasharray="7 9" />
|
||||
<g transform="translate(653 618) scale(2.45)">
|
||||
<path d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50" stroke="#15171A" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</g>
|
||||
<circle cx="795" cy="740" r="14" fill="#EF5A45" />
|
||||
<text x="775" y="865" text-anchor="middle" fill="#15171A" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="20" font-weight="600">ASYMMETRIC / OPEN / CONTINUOUS</text>
|
||||
|
||||
<rect x="1040" y="510" width="500" height="430" rx="8" fill="#15171A" />
|
||||
<circle cx="1140" cy="650" r="52" fill="#FFFFFF" />
|
||||
<circle cx="1290" cy="650" r="52" fill="#EF5A45" />
|
||||
<circle cx="1440" cy="650" r="52" fill="#0E0F11" stroke="#3B3E43" stroke-width="2" />
|
||||
<text x="1140" y="735" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">PAPER</text>
|
||||
<text x="1290" y="735" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">CORAL</text>
|
||||
<text x="1440" y="735" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="18" font-weight="600">CARBON</text>
|
||||
<text x="1290" y="835" text-anchor="middle" fill="#FFFFFF" font-family="Inter, SF Pro Display, Segoe UI, Arial, sans-serif" font-size="34" font-weight="650">Evidence before acceptance.</text>
|
||||
<text x="1290" y="875" text-anchor="middle" fill="#AEB2B8" font-family="Inter, SF Pro Text, Segoe UI, Arial, sans-serif" font-size="21" font-weight="500">The endpoint marks verification, not consensus.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,172 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Trace V3 brand board</title>
|
||||
<desc id="desc">A brand-system board showing the V3 mark, wordmark, app icon, navigation use, supported Agent field, README application, construction logic, and palette.</desc>
|
||||
|
||||
<defs>
|
||||
<g id="trace-mark">
|
||||
<path
|
||||
d="M13 57V33C13 23.4 18.3 17 26.2 17C34.1 17 38.8 23.4 38.8 32.8V56.5C38.8 39.1 44.4 27 52.9 27C61.1 27 65.9 33.5 65.9 42.5V47.5C65.9 53.8 69.1 57 74.7 57"
|
||||
stroke="currentColor"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="85.5" cy="57" r="4.8" fill="#EF5A45" />
|
||||
</g>
|
||||
<path
|
||||
id="word-outline"
|
||||
d="M80.27 43L80.27 20.09L87.19 20.09L91.34 35.72L95.45 20.09L102.39 20.09L102.39 43L98.09 43L98.09 24.97L93.55 43L89.09 43L84.56 24.97L84.56 43ZM116.56 37.72L120.94 38.45Q120.09 40.86 118.27 42.12Q116.45 43.38 113.72 43.38Q109.39 43.38 107.31 40.55Q105.67 38.28 105.67 34.83Q105.67 30.7 107.83 28.37Q109.98 26.03 113.28 26.03Q116.98 26.03 119.13 28.48Q121.27 30.92 121.17 35.97L110.17 35.97Q110.22 37.92 111.23 39.01Q112.25 40.09 113.77 40.09Q114.8 40.09 115.5 39.53Q116.2 38.97 116.56 37.72ZM116.81 33.28Q116.77 31.38 115.83 30.38Q114.89 29.39 113.55 29.39Q112.11 29.39 111.17 30.44Q110.23 31.48 110.25 33.28ZM124.75 43L124.75 20.09L129.14 20.09L129.14 43ZM148.86 43L144.78 43L144.78 40.56Q143.77 41.98 142.38 42.68Q141 43.38 139.59 43.38Q136.73 43.38 134.7 41.07Q132.66 38.77 132.66 34.64Q132.66 30.42 134.64 28.23Q136.63 26.03 139.66 26.03Q142.44 26.03 144.47 28.34L144.47 20.09L148.86 20.09ZM137.14 34.34Q137.14 37 137.88 38.19Q138.94 39.91 140.84 39.91Q142.36 39.91 143.42 38.62Q144.48 37.33 144.48 34.77Q144.48 31.91 143.45 30.65Q142.42 29.39 140.81 29.39Q139.25 29.39 138.2 30.63Q137.14 31.88 137.14 34.34ZM156.28 43L151.03 26.41L155.3 26.41L158.41 37.28L161.27 26.41L165.5 26.41L168.27 37.28L171.44 26.41L175.77 26.41L170.44 43L166.22 43L163.36 32.33L160.55 43ZM177.06 34.47Q177.06 32.28 178.14 30.23Q179.22 28.19 181.2 27.11Q183.17 26.03 185.61 26.03Q189.38 26.03 191.78 28.48Q194.19 30.92 194.19 34.66Q194.19 38.42 191.76 40.9Q189.33 43.38 185.64 43.38Q183.36 43.38 181.29 42.34Q179.22 41.31 178.14 39.32Q177.06 37.33 177.06 34.47ZM181.56 34.7Q181.56 37.17 182.73 38.48Q183.91 39.8 185.63 39.8Q187.34 39.8 188.51 38.48Q189.67 37.17 189.67 34.67Q189.67 32.23 188.51 30.92Q187.34 29.61 185.63 29.61Q183.91 29.61 182.73 30.92Q181.56 32.23 181.56 34.7ZM201.83 43L197.44 43L197.44 26.41L201.52 26.41L201.52 28.77Q202.56 27.09 203.4 26.56Q204.23 26.03 205.3 26.03Q206.8 26.03 208.19 26.86L206.83 30.69Q205.72 29.97 204.77 29.97Q203.84 29.97 203.2 30.48Q202.56 30.98 202.2 32.31Q201.83 33.64 201.83 37.88ZM209.92 43L209.92 20.09L214.31 20.09L214.31 32.25L219.45 26.41L224.86 26.41L219.19 32.47L225.27 43L220.53 43L216.36 35.55L214.31 37.69L214.31 43Z"
|
||||
/>
|
||||
<g id="trace-wordmark">
|
||||
<use href="#trace-mark" />
|
||||
<use href="#word-outline" transform="translate(12 0) scale(1.35)" fill="currentColor" />
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
<rect width="1600" height="1000" fill="#DDE0E4" />
|
||||
|
||||
<!-- Light wordmark cover -->
|
||||
<rect x="60" y="60" width="760" height="390" rx="8" fill="#FFFFFF" />
|
||||
<text x="96" y="108" fill="#858B94" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14" font-weight="700" letter-spacing="2">01 / WORDMARK</text>
|
||||
<g transform="translate(96 145) scale(2.05)" color="#17191C">
|
||||
<use href="#trace-wordmark" />
|
||||
</g>
|
||||
<text x="98" y="380" fill="#17191C" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="22" font-weight="650">TRACE / V3</text>
|
||||
<text x="98" y="410" fill="#6F757E" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="17">Continuous Agent work. Separate human acceptance.</text>
|
||||
|
||||
<!-- Dark README / navigation application -->
|
||||
<rect x="840" y="60" width="700" height="390" rx="8" fill="#0E1013" />
|
||||
<text x="876" y="108" fill="#777F8A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14" font-weight="700" letter-spacing="2">02 / DARK APPLICATION</text>
|
||||
<g transform="translate(875 130) scale(1.25)" color="#F7F8FA">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
<text x="1006" y="198" fill="#F7F8FA" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="30" font-weight="700">Meldwork</text>
|
||||
<text x="876" y="270" fill="#F7F8FA" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="38" font-weight="680">Agents change.</text>
|
||||
<text x="876" y="318" fill="#F7F8FA" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="38" font-weight="680">Work continues.</text>
|
||||
<rect x="876" y="350" width="610" height="62" rx="6" fill="#181B20" stroke="#2F343C" stroke-width="2" />
|
||||
<g transform="translate(893 353) scale(0.66)" color="#F7F8FA">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
<text x="970" y="387" fill="#E7E9ED" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="16" font-weight="620">Persistent workspace for general agents</text>
|
||||
<rect x="1348" y="368" width="112" height="28" rx="4" fill="#242931" />
|
||||
<text x="1404" y="387" text-anchor="middle" fill="#A9B0BA" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="700" letter-spacing="1">LOCAL-FIRST</text>
|
||||
|
||||
<!-- Supported Agent field -->
|
||||
<rect x="60" y="470" width="720" height="470" rx="8" fill="#14171B" />
|
||||
<text x="90" y="515" fill="#79818C" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14" font-weight="700" letter-spacing="2">03 / AGENT FIELD</text>
|
||||
<g transform="translate(60 470)">
|
||||
<g stroke="#59616D" stroke-width="2">
|
||||
<path d="M360 250V104H99" />
|
||||
<path d="M274 94V104" />
|
||||
<path d="M504 104H360" />
|
||||
<path d="M360 250H639V174" />
|
||||
<path d="M360 250H614V299" />
|
||||
<path d="M360 250V404H549V399" />
|
||||
<path d="M360 404H364" />
|
||||
<path d="M360 404H194V394" />
|
||||
<path d="M360 250H84V284" />
|
||||
<path d="M179 219V250" />
|
||||
</g>
|
||||
<g fill="#EF5A45">
|
||||
<circle cx="360" cy="104" r="4" />
|
||||
<circle cx="360" cy="250" r="4" />
|
||||
<circle cx="360" cy="404" r="4" />
|
||||
</g>
|
||||
|
||||
<circle cx="360" cy="250" r="70" fill="#F7F8FA" stroke="#FFFFFF" stroke-width="8" />
|
||||
<g transform="translate(312 214)" color="#17191C">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
|
||||
<g transform="translate(70 80)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/codex.svg" x="11" y="11" width="36" height="36" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">Codex</text>
|
||||
</g>
|
||||
<g transform="translate(245 65)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/claude.png" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">Claude</text>
|
||||
</g>
|
||||
<g transform="translate(475 75)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/kimi.png" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">Kimi</text>
|
||||
</g>
|
||||
<g transform="translate(610 145)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/mimo.svg" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">MiMo</text>
|
||||
</g>
|
||||
<g transform="translate(585 270)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/workbuddy.png" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">WorkBuddy</text>
|
||||
</g>
|
||||
<g transform="translate(520 370)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/gemini.svg" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">Gemini</text>
|
||||
</g>
|
||||
<g transform="translate(335 375)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/opencode.svg" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">OpenCode</text>
|
||||
</g>
|
||||
<g transform="translate(165 365)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/qwen.svg" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">Qwen</text>
|
||||
</g>
|
||||
<g transform="translate(55 255)">
|
||||
<rect width="58" height="58" rx="14" fill="#F2F3F5" stroke="#C9CDD3" />
|
||||
<image href="../agent-logos/hermes.svg" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">Hermes</text>
|
||||
</g>
|
||||
<g transform="translate(150 190)">
|
||||
<rect width="58" height="58" rx="14" fill="#22262C" stroke="#383E47" />
|
||||
<image href="../agent-logos/openclaw-transparent.png" x="10" y="10" width="38" height="38" preserveAspectRatio="xMidYMid meet" />
|
||||
<text x="29" y="78" text-anchor="middle" fill="#AEB4BD" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600">OpenClaw</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- App icon and navigation sizes -->
|
||||
<rect x="800" y="470" width="340" height="470" rx="8" fill="#FFFFFF" />
|
||||
<text x="830" y="515" fill="#858B94" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14" font-weight="700" letter-spacing="2">04 / APP + NAV</text>
|
||||
<rect x="875" y="550" width="190" height="190" rx="42" fill="#0E1013" />
|
||||
<g transform="translate(894 581) scale(1.78)" color="#F7F8FA">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
<rect x="830" y="790" width="280" height="52" rx="6" fill="#F4F5F7" />
|
||||
<g transform="translate(844 793) scale(0.5)" color="#17191C">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
<text x="905" y="822" fill="#17191C" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="650">Light navigation</text>
|
||||
<rect x="830" y="856" width="280" height="52" rx="6" fill="#171A1F" />
|
||||
<g transform="translate(844 859) scale(0.5)" color="#F7F8FA">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
<text x="905" y="888" fill="#F7F8FA" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="650">Dark navigation</text>
|
||||
|
||||
<!-- Construction and palette -->
|
||||
<rect x="1160" y="470" width="380" height="470" rx="8" fill="#F7F8F9" />
|
||||
<text x="1190" y="515" fill="#858B94" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14" font-weight="700" letter-spacing="2">05 / TRACE + ACCEPTANCE</text>
|
||||
<line x1="1190" y1="680" x2="1510" y2="680" stroke="#D4D8DE" stroke-width="2" stroke-dasharray="7 8" />
|
||||
<g transform="translate(1193 555) scale(3.05)" color="#17191C">
|
||||
<use href="#trace-mark" />
|
||||
</g>
|
||||
<text x="1190" y="775" fill="#17191C" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="700">AGENT TRACE</text>
|
||||
<path d="M1245 757V724" stroke="#17191C" stroke-width="2" />
|
||||
<text x="1310" y="812" fill="#757B84" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="650">REVIEW GAP</text>
|
||||
<path d="M1360 793V730" stroke="#A9AFB7" stroke-width="2" />
|
||||
<text x="1400" y="775" fill="#EF5A45" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="700">ACCEPTANCE</text>
|
||||
<path d="M1454 757V734" stroke="#EF5A45" stroke-width="2" />
|
||||
<circle cx="1212" cy="860" r="22" fill="#17191C" />
|
||||
<circle cx="1280" cy="860" r="22" fill="#FFFFFF" stroke="#D8DCE1" />
|
||||
<circle cx="1348" cy="860" r="22" fill="#EF5A45" />
|
||||
<circle cx="1416" cy="860" r="22" fill="#0E1013" />
|
||||
<text x="1190" y="916" fill="#777D86" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" letter-spacing="1.2">INK / PAPER / CORAL / CARBON</text>
|
||||
|
||||
<text x="60" y="976" fill="#7C828A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" letter-spacing="1.8">MELDWORK / TRACE V3 / IDENTITY SYSTEM</text>
|
||||
<text x="1540" y="976" text-anchor="end" fill="#7C828A" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700">03</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 522 B After Width: | Height: | Size: 427 B |
|
Before Width: | Height: | Size: 928 B After Width: | Height: | Size: 798 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="3 10 60 45" fill="none" role="img" aria-labelledby="title">
|
||||
<title id="title">Meldwork Open Stitch mark for dark backgrounds</title>
|
||||
<path
|
||||
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="8.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 508 B |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="3 10 60 45" fill="none" role="img" aria-labelledby="title">
|
||||
<title id="title">Meldwork Open Stitch mark</title>
|
||||
<path
|
||||
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
|
||||
stroke="#15171A"
|
||||
stroke-width="8.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 487 B |
@@ -1,22 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 72" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Trace mark</title>
|
||||
<desc id="desc">An asymmetric continuous work line ending before a separate coral acceptance point.</desc>
|
||||
<path
|
||||
d="M8 50V29C8 20.5 12.7 15 19 15C25.3 15 29.5 20.2 29.5 28.5V49"
|
||||
stroke="#4968E8"
|
||||
d="M13 57V33C13 23.4 18.3 17 26.2 17C34.1 17 38.8 23.4 38.8 32.8V56.5C38.8 39.1 44.4 27 52.9 27C61.1 27 65.9 33.5 65.9 42.5V47.5C65.9 53.8 69.1 57 74.7 57"
|
||||
stroke="#17191C"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.5 35C29.5 26.7 34 21 40.5 21C47 21 51 26.2 51 34.5V39C51 45.1 53.3 48.5 58 50"
|
||||
stroke="#4968E8"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M55.2 48.7C56.1 49.2 57 49.6 58 50"
|
||||
stroke="#FF7A61"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="85.5" cy="57" r="4.8" fill="#EF5A45" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 584 B After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,17 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 10 236 45" fill="none" role="img" aria-labelledby="title">
|
||||
<title id="title">Meldwork wordmark for dark backgrounds</title>
|
||||
<path
|
||||
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="8.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
<g transform="translate(2 0)">
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M80.27 43L80.27 20.09L87.19 20.09L91.34 35.72L95.45 20.09L102.39 20.09L102.39 43L98.09 43L98.09 24.97L93.55 43L89.09 43L84.56 24.97L84.56 43ZM116.56 37.72L120.94 38.45Q120.09 40.86 118.27 42.12Q116.45 43.38 113.72 43.38Q109.39 43.38 107.31 40.55Q105.67 38.28 105.67 34.83Q105.67 30.7 107.83 28.37Q109.98 26.03 113.28 26.03Q116.98 26.03 119.13 28.48Q121.27 30.92 121.17 35.97L110.17 35.97Q110.22 37.92 111.23 39.01Q112.25 40.09 113.77 40.09Q114.8 40.09 115.5 39.53Q116.2 38.97 116.56 37.72ZM116.81 33.28Q116.77 31.38 115.83 30.38Q114.89 29.39 113.55 29.39Q112.11 29.39 111.17 30.44Q110.23 31.48 110.25 33.28ZM124.75 43L124.75 20.09L129.14 20.09L129.14 43ZM148.86 43L144.78 43L144.78 40.56Q143.77 41.98 142.38 42.68Q141 43.38 139.59 43.38Q136.73 43.38 134.7 41.07Q132.66 38.77 132.66 34.64Q132.66 30.42 134.64 28.23Q136.63 26.03 139.66 26.03Q142.44 26.03 144.47 28.34L144.47 20.09L148.86 20.09ZM137.14 34.34Q137.14 37 137.88 38.19Q138.94 39.91 140.84 39.91Q142.36 39.91 143.42 38.62Q144.48 37.33 144.48 34.77Q144.48 31.91 143.45 30.65Q142.42 29.39 140.81 29.39Q139.25 29.39 138.2 30.63Q137.14 31.88 137.14 34.34ZM156.28 43L151.03 26.41L155.3 26.41L158.41 37.28L161.27 26.41L165.5 26.41L168.27 37.28L171.44 26.41L175.77 26.41L170.44 43L166.22 43L163.36 32.33L160.55 43ZM177.06 34.47Q177.06 32.28 178.14 30.23Q179.22 28.19 181.2 27.11Q183.17 26.03 185.61 26.03Q189.38 26.03 191.78 28.48Q194.19 30.92 194.19 34.66Q194.19 38.42 191.76 40.9Q189.33 43.38 185.64 43.38Q183.36 43.38 181.29 42.34Q179.22 41.31 178.14 39.32Q177.06 37.33 177.06 34.47ZM181.56 34.7Q181.56 37.17 182.73 38.48Q183.91 39.8 185.63 39.8Q187.34 39.8 188.51 38.48Q189.67 37.17 189.67 34.67Q189.67 32.23 188.51 30.92Q187.34 29.61 185.63 29.61Q183.91 29.61 182.73 30.92Q181.56 32.23 181.56 34.7ZM201.83 43L197.44 43L197.44 26.41L201.52 26.41L201.52 28.77Q202.56 27.09 203.4 26.56Q204.23 26.03 205.3 26.03Q206.8 26.03 208.19 26.86L206.83 30.69Q205.72 29.97 204.77 29.97Q203.84 29.97 203.2 30.48Q202.56 30.98 202.2 32.31Q201.83 33.64 201.83 37.88ZM209.92 43L209.92 20.09L214.31 20.09L214.31 32.25L219.45 26.41L224.86 26.41L219.19 32.47L225.27 43L220.53 43L216.36 35.55L214.31 37.69L214.31 43Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326 72" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Trace wordmark for dark backgrounds</title>
|
||||
<desc id="desc">The Meldwork name paired with the asymmetric Trace mark and coral acceptance point.</desc>
|
||||
<path
|
||||
d="M13 57V33C13 23.4 18.3 17 26.2 17C34.1 17 38.8 23.4 38.8 32.8V56.5C38.8 39.1 44.4 27 52.9 27C61.1 27 65.9 33.5 65.9 42.5V47.5C65.9 53.8 69.1 57 74.7 57"
|
||||
stroke="#F7F8FA"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="85.5" cy="57" r="4.8" fill="#EF5A45" />
|
||||
<path
|
||||
transform="translate(12 0) scale(1.35)"
|
||||
fill="#F7F8FA"
|
||||
d="M80.27 43L80.27 20.09L87.19 20.09L91.34 35.72L95.45 20.09L102.39 20.09L102.39 43L98.09 43L98.09 24.97L93.55 43L89.09 43L84.56 24.97L84.56 43ZM116.56 37.72L120.94 38.45Q120.09 40.86 118.27 42.12Q116.45 43.38 113.72 43.38Q109.39 43.38 107.31 40.55Q105.67 38.28 105.67 34.83Q105.67 30.7 107.83 28.37Q109.98 26.03 113.28 26.03Q116.98 26.03 119.13 28.48Q121.27 30.92 121.17 35.97L110.17 35.97Q110.22 37.92 111.23 39.01Q112.25 40.09 113.77 40.09Q114.8 40.09 115.5 39.53Q116.2 38.97 116.56 37.72ZM116.81 33.28Q116.77 31.38 115.83 30.38Q114.89 29.39 113.55 29.39Q112.11 29.39 111.17 30.44Q110.23 31.48 110.25 33.28ZM124.75 43L124.75 20.09L129.14 20.09L129.14 43ZM148.86 43L144.78 43L144.78 40.56Q143.77 41.98 142.38 42.68Q141 43.38 139.59 43.38Q136.73 43.38 134.7 41.07Q132.66 38.77 132.66 34.64Q132.66 30.42 134.64 28.23Q136.63 26.03 139.66 26.03Q142.44 26.03 144.47 28.34L144.47 20.09L148.86 20.09ZM137.14 34.34Q137.14 37 137.88 38.19Q138.94 39.91 140.84 39.91Q142.36 39.91 143.42 38.62Q144.48 37.33 144.48 34.77Q144.48 31.91 143.45 30.65Q142.42 29.39 140.81 29.39Q139.25 29.39 138.2 30.63Q137.14 31.88 137.14 34.34ZM156.28 43L151.03 26.41L155.3 26.41L158.41 37.28L161.27 26.41L165.5 26.41L168.27 37.28L171.44 26.41L175.77 26.41L170.44 43L166.22 43L163.36 32.33L160.55 43ZM177.06 34.47Q177.06 32.28 178.14 30.23Q179.22 28.19 181.2 27.11Q183.17 26.03 185.61 26.03Q189.38 26.03 191.78 28.48Q194.19 30.92 194.19 34.66Q194.19 38.42 191.76 40.9Q189.33 43.38 185.64 43.38Q183.36 43.38 181.29 42.34Q179.22 41.31 178.14 39.32Q177.06 37.33 177.06 34.47ZM181.56 34.7Q181.56 37.17 182.73 38.48Q183.91 39.8 185.63 39.8Q187.34 39.8 188.51 38.48Q189.67 37.17 189.67 34.67Q189.67 32.23 188.51 30.92Q187.34 29.61 185.63 29.61Q183.91 29.61 182.73 30.92Q181.56 32.23 181.56 34.7ZM201.83 43L197.44 43L197.44 26.41L201.52 26.41L201.52 28.77Q202.56 27.09 203.4 26.56Q204.23 26.03 205.3 26.03Q206.8 26.03 208.19 26.86L206.83 30.69Q205.72 29.97 204.77 29.97Q203.84 29.97 203.2 30.48Q202.56 30.98 202.2 32.31Q201.83 33.64 201.83 37.88ZM209.92 43L209.92 20.09L214.31 20.09L214.31 32.25L219.45 26.41L224.86 26.41L219.19 32.47L225.27 43L220.53 43L216.36 35.55L214.31 37.69L214.31 43Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,17 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 10 236 45" fill="none" role="img" aria-labelledby="title">
|
||||
<title id="title">Meldwork wordmark</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326 72" fill="none" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Meldwork Trace wordmark</title>
|
||||
<desc id="desc">The Meldwork name paired with the asymmetric Trace mark and coral acceptance point.</desc>
|
||||
<path
|
||||
d="M8 50V29.5C8 20.8 12.5 15 19.5 15C26.2 15 30 20.5 30 29V49C30 31.5 34.7 20.5 41 20.5C47.7 20.5 51.5 26 51.5 34.5V39.5C51.5 45.2 53.7 48.6 58 50"
|
||||
stroke="#15171A"
|
||||
stroke-width="8.5"
|
||||
d="M13 57V33C13 23.4 18.3 17 26.2 17C34.1 17 38.8 23.4 38.8 32.8V56.5C38.8 39.1 44.4 27 52.9 27C61.1 27 65.9 33.5 65.9 42.5V47.5C65.9 53.8 69.1 57 74.7 57"
|
||||
stroke="#17191C"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="58" cy="50" r="4.25" fill="#EF5A45" />
|
||||
<g transform="translate(2 0)">
|
||||
<path
|
||||
fill="#15171A"
|
||||
<circle cx="85.5" cy="57" r="4.8" fill="#EF5A45" />
|
||||
<path
|
||||
transform="translate(12 0) scale(1.35)"
|
||||
fill="#17191C"
|
||||
d="M80.27 43L80.27 20.09L87.19 20.09L91.34 35.72L95.45 20.09L102.39 20.09L102.39 43L98.09 43L98.09 24.97L93.55 43L89.09 43L84.56 24.97L84.56 43ZM116.56 37.72L120.94 38.45Q120.09 40.86 118.27 42.12Q116.45 43.38 113.72 43.38Q109.39 43.38 107.31 40.55Q105.67 38.28 105.67 34.83Q105.67 30.7 107.83 28.37Q109.98 26.03 113.28 26.03Q116.98 26.03 119.13 28.48Q121.27 30.92 121.17 35.97L110.17 35.97Q110.22 37.92 111.23 39.01Q112.25 40.09 113.77 40.09Q114.8 40.09 115.5 39.53Q116.2 38.97 116.56 37.72ZM116.81 33.28Q116.77 31.38 115.83 30.38Q114.89 29.39 113.55 29.39Q112.11 29.39 111.17 30.44Q110.23 31.48 110.25 33.28ZM124.75 43L124.75 20.09L129.14 20.09L129.14 43ZM148.86 43L144.78 43L144.78 40.56Q143.77 41.98 142.38 42.68Q141 43.38 139.59 43.38Q136.73 43.38 134.7 41.07Q132.66 38.77 132.66 34.64Q132.66 30.42 134.64 28.23Q136.63 26.03 139.66 26.03Q142.44 26.03 144.47 28.34L144.47 20.09L148.86 20.09ZM137.14 34.34Q137.14 37 137.88 38.19Q138.94 39.91 140.84 39.91Q142.36 39.91 143.42 38.62Q144.48 37.33 144.48 34.77Q144.48 31.91 143.45 30.65Q142.42 29.39 140.81 29.39Q139.25 29.39 138.2 30.63Q137.14 31.88 137.14 34.34ZM156.28 43L151.03 26.41L155.3 26.41L158.41 37.28L161.27 26.41L165.5 26.41L168.27 37.28L171.44 26.41L175.77 26.41L170.44 43L166.22 43L163.36 32.33L160.55 43ZM177.06 34.47Q177.06 32.28 178.14 30.23Q179.22 28.19 181.2 27.11Q183.17 26.03 185.61 26.03Q189.38 26.03 191.78 28.48Q194.19 30.92 194.19 34.66Q194.19 38.42 191.76 40.9Q189.33 43.38 185.64 43.38Q183.36 43.38 181.29 42.34Q179.22 41.31 178.14 39.32Q177.06 37.33 177.06 34.47ZM181.56 34.7Q181.56 37.17 182.73 38.48Q183.91 39.8 185.63 39.8Q187.34 39.8 188.51 38.48Q189.67 37.17 189.67 34.67Q189.67 32.23 188.51 30.92Q187.34 29.61 185.63 29.61Q183.91 29.61 182.73 30.92Q181.56 32.23 181.56 34.7ZM201.83 43L197.44 43L197.44 26.41L201.52 26.41L201.52 28.77Q202.56 27.09 203.4 26.56Q204.23 26.03 205.3 26.03Q206.8 26.03 208.19 26.86L206.83 30.69Q205.72 29.97 204.77 29.97Q203.84 29.97 203.2 30.48Q202.56 30.98 202.2 32.31Q201.83 33.64 201.83 37.88ZM209.92 43L209.92 20.09L214.31 20.09L214.31 32.25L219.45 26.41L224.86 26.41L219.19 32.47L225.27 43L220.53 43L216.36 35.55L214.31 37.69L214.31 43Z"
|
||||
/>
|
||||
</g>
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -22,9 +22,11 @@
|
||||
<header class="brand-row">
|
||||
<button
|
||||
class="brand-button"
|
||||
:class="{ active: activeView === 'home' }"
|
||||
type="button"
|
||||
:title="t('nav.home')"
|
||||
:aria-label="t('nav.home')"
|
||||
:aria-current="activeView === 'home' ? 'page' : undefined"
|
||||
@click="goHome"
|
||||
>
|
||||
<img :src="productMark" alt="" />
|
||||
@@ -272,6 +274,7 @@
|
||||
:class="{ active: activeView === 'settings' }"
|
||||
type="button"
|
||||
:title="t('nav.settings')"
|
||||
:aria-current="activeView === 'settings' ? 'page' : undefined"
|
||||
@click="openSystemSettings('agents')"
|
||||
>
|
||||
<SettingsOutline />
|
||||
@@ -449,16 +452,18 @@
|
||||
<h2>{{ t('knowledgeBase.title') }}</h2>
|
||||
<p>{{ t('knowledgeBase.subtitle') }}</p>
|
||||
</div>
|
||||
<button class="secondary-button compact" type="button" :disabled="knowledgeBaseLoading" @click="loadKnowledgeBaseStatuses">
|
||||
<RefreshOutline :class="{ spinning: knowledgeBaseLoading }" />
|
||||
{{ t('knowledgeBase.refresh') }}
|
||||
</button>
|
||||
<div class="knowledge-base-header-actions">
|
||||
<span class="knowledge-base-ready-summary" role="status" aria-live="polite">
|
||||
{{ knowledgeBaseLoading
|
||||
? t('knowledgeBase.status.checking')
|
||||
: t('knowledgeBase.readyCount', { ready: readyKnowledgeBaseCount, total: localKnowledgeBaseEntries.length }) }}
|
||||
</span>
|
||||
<button class="secondary-button compact" type="button" :disabled="knowledgeBaseLoading" @click="loadKnowledgeBaseStatuses">
|
||||
<RefreshOutline :class="{ spinning: knowledgeBaseLoading }" />
|
||||
{{ t('knowledgeBase.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manager-toolbar" role="status" aria-live="polite">
|
||||
<span>{{ knowledgeBaseLoading
|
||||
? t('knowledgeBase.status.checking')
|
||||
: t('knowledgeBase.readyCount', { ready: readyKnowledgeBaseCount, total: localKnowledgeBaseEntries.length }) }}</span>
|
||||
</div>
|
||||
<div class="knowledge-base-groups">
|
||||
<section
|
||||
v-for="group in knowledgeBaseGroups"
|
||||
@@ -469,37 +474,53 @@
|
||||
>
|
||||
<header class="knowledge-base-group-header">
|
||||
<h3 :id="`knowledge-base-group-${group.id}`">{{ t(group.titleKey) }}</h3>
|
||||
<p v-if="group.planned">{{ t('knowledgeBase.group.plannedHint') }}</p>
|
||||
</header>
|
||||
<div class="knowledge-base-grid">
|
||||
<div v-if="group.planned" class="knowledge-base-planned-list">
|
||||
<article
|
||||
v-for="source in group.sources"
|
||||
:key="source.kind"
|
||||
class="knowledge-base-planned-item"
|
||||
>
|
||||
<img class="knowledge-base-logo" :src="source.logo" :alt="t(`knowledgeBase.source.${source.kind}`)" />
|
||||
<div class="knowledge-base-planned-copy">
|
||||
<strong>{{ t(`knowledgeBase.source.${source.kind}`) }}</strong>
|
||||
<p>{{ t(`knowledgeBase.description.${source.kind}`) }}</p>
|
||||
</div>
|
||||
<span class="knowledge-base-planned-status">
|
||||
<CloudOutline />
|
||||
{{ knowledgeBaseStatusLabel(source) }}
|
||||
</span>
|
||||
<button class="knowledge-base-doc-link" type="button" @click="runKnowledgeBasePrimaryAction(source)">
|
||||
{{ knowledgeBasePrimaryActionLabel(source) }}
|
||||
<ChevronForwardOutline />
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="knowledge-base-grid">
|
||||
<article
|
||||
v-for="source in group.sources"
|
||||
:key="source.kind"
|
||||
class="knowledge-base-card"
|
||||
:class="{ planned: group.planned, pending: knowledgeBasePending(source) }"
|
||||
:class="{ pending: knowledgeBasePending(source) }"
|
||||
>
|
||||
<header class="knowledge-base-card-header">
|
||||
<img class="knowledge-base-logo" :src="source.logo" :alt="t(`knowledgeBase.source.${source.kind}`)" />
|
||||
<div class="knowledge-base-card-copy">
|
||||
<div class="knowledge-base-card-title">
|
||||
<strong>{{ t(`knowledgeBase.source.${source.kind}`) }}</strong>
|
||||
<span class="knowledge-base-status" :class="knowledgeBaseTone(source)">
|
||||
<component :is="knowledgeBaseIcon(source)" :class="{ spinning: knowledgeBasePending(source) }" />
|
||||
{{ knowledgeBaseStatusLabel(source) }}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{{ t(`knowledgeBase.source.${source.kind}`) }}</strong>
|
||||
<p>{{ t(`knowledgeBase.description.${source.kind}`) }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="knowledge-base-tags">
|
||||
<span
|
||||
v-for="tag in knowledgeBaseTags(source)"
|
||||
:key="tag.label"
|
||||
class="knowledge-base-tag"
|
||||
:class="tag.tone"
|
||||
>
|
||||
{{ tag.label }}
|
||||
<span class="knowledge-base-status" :class="knowledgeBaseTone(source)">
|
||||
<component :is="knowledgeBaseIcon(source)" :class="{ spinning: knowledgeBasePending(source) }" />
|
||||
{{ knowledgeBaseStatusLabel(source) }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="knowledge-base-facts">
|
||||
<div v-for="fact in knowledgeBaseFacts(source)" :key="fact.key">
|
||||
<dt>{{ fact.label }}</dt>
|
||||
<dd :class="fact.tone">{{ fact.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="knowledge-base-hint">{{ knowledgeBaseHint(source) }}</p>
|
||||
<p v-if="knowledgeBaseLocationLabel(source)" class="knowledge-base-path">
|
||||
<code>{{ knowledgeBaseLocationLabel(source) }}</code>
|
||||
@@ -512,6 +533,7 @@
|
||||
>
|
||||
<RefreshOutline v-if="knowledgeBasePending(source)" class="spinning" />
|
||||
{{ knowledgeBasePrimaryActionLabel(source) }}
|
||||
<ChevronForwardOutline v-if="!knowledgeBasePending(source)" />
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
@@ -540,10 +562,9 @@
|
||||
@click="selectProviderAgent(agent.kind)"
|
||||
>
|
||||
<img :src="agent.logo" alt="" />
|
||||
<span>
|
||||
<span class="provider-agent-copy">
|
||||
<strong>{{ agent.label }}</strong>
|
||||
<small :class="providerStatusTone(agent.kind)">{{ providerStatusLabel(agent.kind) }}</small>
|
||||
<small class="provider-agent-mode">{{ providerCapabilityLabel(agent) }}</small>
|
||||
</span>
|
||||
<CheckmarkCircleOutline v-if="providerReady(agent.kind)" class="ready" />
|
||||
<RefreshOutline v-else-if="!hasProviderStatus(agent.kind)" class="spinning" />
|
||||
@@ -551,31 +572,24 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<form class="provider-editor form-stack" :aria-busy="String(providerNativeStatusPending)" @submit.prevent="saveProvider">
|
||||
<form class="provider-editor form-stack" :aria-busy="String(!hasProviderStatus(selectedProviderKind))" @submit.prevent="saveProvider">
|
||||
<header v-if="selectedProviderAgent" class="provider-editor-header">
|
||||
<img :src="selectedProviderAgent.logo" alt="" />
|
||||
<div>
|
||||
<div class="provider-editor-title">
|
||||
<h2>{{ t('provider.agentTitle', { agent: selectedProviderAgent.label }) }}</h2>
|
||||
<p>{{ agentDescription(selectedProviderAgent.kind) }}</p>
|
||||
<small class="provider-capability-label">{{ providerCapabilityLabel(selectedProviderAgent) }}</small>
|
||||
<div class="provider-editor-tags">
|
||||
<span :class="providerStatusTone(selectedProviderKind)">{{ providerStatusLabel(selectedProviderKind) }}</span>
|
||||
<span>{{ providerCapabilityLabel(selectedProviderAgent) }}</span>
|
||||
<span class="active">{{ t('provider.activeProfile', { provider: providerPresetLabel(providerActivePreset) }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="provider-status" role="status" aria-live="polite" :class="{ configured: providerReady(selectedProviderKind), checking: !hasProviderStatus(selectedProviderKind) }">
|
||||
<CheckmarkCircleOutline v-if="providerReady(selectedProviderKind)" />
|
||||
<RefreshOutline v-else-if="!hasProviderStatus(selectedProviderKind)" class="spinning" />
|
||||
<WarningOutline v-else />
|
||||
<span>{{ providerStatusLabel(selectedProviderKind) }}</span>
|
||||
</div>
|
||||
<p class="provider-agent-hint">{{ t('provider.agentSpecificHint') }}</p>
|
||||
<p
|
||||
v-if="nativeProviderReady(selectedProviderAgent) && hasProviderStatus(selectedProviderKind) && !providerStatus.error && !providerStatus.configured"
|
||||
class="provider-native-detected-note"
|
||||
>
|
||||
<CheckmarkCircleOutline />
|
||||
<span>{{ t('provider.nativeDetectedHint') }}</span>
|
||||
</p>
|
||||
|
||||
<section class="provider-source-section" :aria-label="t('provider.source')">
|
||||
<span>{{ t('provider.source') }}</span>
|
||||
<div class="provider-source-heading">
|
||||
<span>{{ t('provider.source') }}</span>
|
||||
<small v-if="selectedProviderPreset">{{ providerPresetHint(selectedProviderPreset.id) }}</small>
|
||||
</div>
|
||||
<div class="provider-source-options">
|
||||
<button
|
||||
v-for="preset in selectedProviderPresets"
|
||||
@@ -587,93 +601,70 @@
|
||||
@click="applyProviderPreset(preset.id)"
|
||||
>
|
||||
<strong>{{ providerPresetLabel(preset.id) }}</strong>
|
||||
<small>{{ providerPresetHint(preset.id) }}</small>
|
||||
<small :class="{ active: providerPresetActive(preset.id), configured: providerPresetConfigured(preset.id) }">
|
||||
{{ providerPresetStateLabel(preset.id) }}
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-if="providerNativeOfficialMode"
|
||||
class="provider-native-card"
|
||||
:class="{ warning: providerNativeOverrideActive || providerNativeStatusError }"
|
||||
>
|
||||
<RefreshOutline v-if="providerNativeStatusPending" class="spinning" />
|
||||
<WarningOutline v-else-if="providerNativeOverrideActive || providerNativeStatusError" />
|
||||
<TerminalOutline v-else />
|
||||
<div>
|
||||
<strong>{{ t(providerNativeStatusPending
|
||||
? 'provider.nativeStatusCheckingTitle'
|
||||
: providerNativeStatusError
|
||||
? 'provider.nativeStatusErrorTitle'
|
||||
: providerNativeOverrideActive
|
||||
? 'provider.nativeOverrideTitle'
|
||||
: 'provider.nativeTitle') }}</strong>
|
||||
<p>{{ t(providerNativeStatusPending
|
||||
? 'provider.nativeStatusCheckingBody'
|
||||
: providerNativeStatusError
|
||||
? 'provider.nativeStatusErrorBody'
|
||||
: providerNativeOverrideActive
|
||||
? 'provider.nativeOverrideBody'
|
||||
: 'provider.nativeBody') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="providerStatus.error" class="provider-inline-warning" role="status">
|
||||
<span>{{ t('provider.unavailable') }}</span>
|
||||
<button type="button" :disabled="saving" @click="retryProviderStatus(selectedProviderKind)">
|
||||
<RefreshOutline />
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="providerStatus.encryptionAvailable === false" class="form-error">
|
||||
{{ t('provider.encryptionUnavailable') }}
|
||||
</p>
|
||||
<label v-if="!providerNativeOfficialMode">
|
||||
<span>{{ t('provider.name') }}</span>
|
||||
<input v-model.trim="providerForm.provider" :placeholder="t('provider.namePlaceholder')" autocomplete="off" maxlength="80" :disabled="saving" required />
|
||||
</label>
|
||||
<label v-if="!providerNativeOfficialMode">
|
||||
<span>{{ t('provider.baseUrl') }}</span>
|
||||
<input v-model.trim="providerForm.baseUrl" :placeholder="t('provider.baseUrlPlaceholder')" inputmode="url" autocomplete="off" maxlength="300" :disabled="saving" required />
|
||||
</label>
|
||||
<label v-if="!providerNativeOfficialMode">
|
||||
<span>{{ t('provider.model') }}</span>
|
||||
<input v-model.trim="providerForm.model" :placeholder="t('provider.modelPlaceholder')" autocomplete="off" maxlength="160" :disabled="saving" required />
|
||||
</label>
|
||||
<label v-if="!providerNativeOfficialMode">
|
||||
<span>{{ t('provider.apiKey') }}</span>
|
||||
<input v-model="providerForm.apiKey" type="password" :placeholder="t('provider.apiKeyPlaceholder')" autocomplete="new-password" maxlength="8192" :disabled="saving" required />
|
||||
</label>
|
||||
<section class="provider-doc-card">
|
||||
<div>
|
||||
<strong>{{ t('provider.configGuide') }}</strong>
|
||||
<p>{{ t(selectedProviderProfile.docsKey) }}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div v-if="selectedProviderProfile.configFile">
|
||||
<dt>{{ t('provider.configFile') }}</dt>
|
||||
<dd><code>{{ selectedProviderProfile.configFile }}</code></dd>
|
||||
</div>
|
||||
<div v-if="selectedProviderRuntimeKeys.length">
|
||||
<dt>{{ t('provider.runtimeKeys') }}</dt>
|
||||
<dd>
|
||||
<code v-for="key in selectedProviderRuntimeKeys" :key="key">{{ key }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div v-if="selectedProviderPresetConfigured" class="provider-profile-summary">
|
||||
<span :class="{ active: selectedProviderPresetActive }">
|
||||
{{ selectedProviderPresetActive ? t('provider.active') : t('provider.configured') }}
|
||||
</span>
|
||||
<span v-if="providerNativeOfficialMode">{{ t('provider.nativeReady') }}</span>
|
||||
<span v-else-if="selectedProviderProfileStatus?.provider">{{ selectedProviderProfileStatus.provider }}</span>
|
||||
<span v-if="selectedProviderProfileStatus?.model">{{ selectedProviderProfileStatus.model }}</span>
|
||||
<code v-if="providerNativeOfficialMode && selectedProviderProfile.configFile">{{ selectedProviderProfile.configFile }}</code>
|
||||
</div>
|
||||
|
||||
<div v-if="!providerNativeOfficialMode" class="provider-external-fields">
|
||||
<label class="provider-field-name">
|
||||
<span>{{ t('provider.name') }}</span>
|
||||
<input v-model.trim="providerForm.provider" :placeholder="t('provider.namePlaceholder')" autocomplete="off" maxlength="80" :disabled="saving" required />
|
||||
</label>
|
||||
<label class="provider-field-url">
|
||||
<span>{{ t('provider.baseUrl') }}</span>
|
||||
<input v-model.trim="providerForm.baseUrl" :placeholder="t('provider.baseUrlPlaceholder')" inputmode="url" autocomplete="off" maxlength="300" :disabled="saving" required />
|
||||
</label>
|
||||
<label class="provider-field-model">
|
||||
<span>{{ t('provider.model') }}</span>
|
||||
<input v-model.trim="providerForm.model" :placeholder="t('provider.modelPlaceholder')" autocomplete="off" maxlength="160" :disabled="saving" required />
|
||||
</label>
|
||||
<label class="provider-field-key">
|
||||
<span>{{ t('provider.apiKey') }}</span>
|
||||
<input v-model="providerForm.apiKey" type="password" :placeholder="t('provider.apiKeyPlaceholder')" autocomplete="new-password" maxlength="8192" :disabled="saving" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="form-error">{{ formError }}</p>
|
||||
<footer class="provider-editor-footer">
|
||||
<button v-if="providerStatus.configured" class="danger-button" type="button" :disabled="saving" @click="removeProvider">
|
||||
<button v-if="selectedProviderProfileSaved" class="danger-button" type="button" :disabled="saving" @click="removeProvider">
|
||||
<TrashOutline />
|
||||
{{ providerRemoveArmed
|
||||
? t(providerNativeOverrideActive ? 'provider.removeOverrideConfirm' : 'provider.removeConfirm')
|
||||
: t(providerNativeOverrideActive ? 'provider.removeOverride' : 'provider.remove') }}
|
||||
{{ providerRemoveArmed ? t('provider.removeConfirm') : t('provider.remove') }}
|
||||
</button>
|
||||
<span class="footer-spacer" />
|
||||
<span v-if="providerNativeConfigSelected" class="provider-native-footer-note">
|
||||
{{ t('provider.nativeConfiguredHint') }}
|
||||
</span>
|
||||
<span v-else-if="providerNativeOverrideActive" class="provider-native-footer-note warning">
|
||||
{{ t('provider.nativeOverrideFooterHint') }}
|
||||
</span>
|
||||
<span v-else-if="providerNativeStatusError" class="provider-native-footer-note warning">
|
||||
{{ t('provider.nativeStatusErrorFooterHint') }}
|
||||
</span>
|
||||
<span v-else-if="providerNativeStatusPending" class="provider-native-footer-note">
|
||||
{{ t('provider.checking') }}
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedProviderPresetConfigured && !selectedProviderPresetActive"
|
||||
class="secondary-button provider-activate-button"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="activateProviderPreset(providerForm.preset)"
|
||||
>
|
||||
<CheckmarkCircleOutline />
|
||||
{{ t('provider.useProfile', { provider: providerPresetLabel(providerForm.preset) }) }}
|
||||
</button>
|
||||
<button v-if="!providerNativeOfficialMode" class="primary-button" type="submit" :disabled="saving || providerStatus.encryptionAvailable === false">
|
||||
{{ saving ? t('common.saving') : t('provider.save') }}
|
||||
</button>
|
||||
@@ -689,41 +680,13 @@
|
||||
<h1>{{ t('home.dashboardTitle') }}</h1>
|
||||
<p>{{ t('home.dashboardSubtitle') }}</p>
|
||||
</div>
|
||||
<div class="home-dashboard-actions">
|
||||
<button class="secondary-button" type="button" :disabled="refreshing" @click="refreshAgents">
|
||||
<RefreshOutline :class="{ spinning: refreshing }" />
|
||||
<span>{{ refreshing ? t('home.refreshing') : t('home.refresh') }}</span>
|
||||
</button>
|
||||
<button class="primary-button" type="button" @click="openNewGroup">
|
||||
<AddOutline />
|
||||
<span>{{ t('nav.newGroup') }}</span>
|
||||
</button>
|
||||
<div class="home-workspace-state" :class="{ attention: showSetupGuide }" role="status">
|
||||
<WarningOutline v-if="showSetupGuide" />
|
||||
<CheckmarkCircleOutline v-else />
|
||||
<span>{{ homeWorkspaceSummary }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="home-overview-grid" :aria-label="t('home.overview')">
|
||||
<article class="home-overview-item">
|
||||
<span>{{ t('home.readyAgents') }}</span>
|
||||
<strong>{{ readyCount }}</strong>
|
||||
<small>{{ t('home.installedAgents', { count: installedCount }) }}</small>
|
||||
</article>
|
||||
<article class="home-overview-item">
|
||||
<span>{{ t('home.connectedProviders') }}</span>
|
||||
<strong>{{ providerConfiguredCount }}</strong>
|
||||
<small>{{ t('home.providerTotal', { count: configurableProviderAgents.length }) }}</small>
|
||||
</article>
|
||||
<article class="home-overview-item">
|
||||
<span>{{ t('home.knowledgeSources') }}</span>
|
||||
<strong>{{ readyKnowledgeBaseCount }}</strong>
|
||||
<small>{{ t('home.knowledgeTotal', { count: localKnowledgeBaseEntries.length }) }}</small>
|
||||
</article>
|
||||
<article class="home-overview-item">
|
||||
<span>{{ t('home.conversations') }}</span>
|
||||
<strong>{{ snapshot.groups.length }}</strong>
|
||||
<small>{{ t('home.activeRuns', { count: snapshot.runningGroupIds.length }) }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="home-dashboard-grid">
|
||||
<section class="home-panel home-recent-panel">
|
||||
<header class="home-panel-header">
|
||||
@@ -731,15 +694,12 @@
|
||||
<h2>{{ t('home.recentTitle') }}</h2>
|
||||
<p>{{ t('home.recentSubtitle') }}</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" :title="t('home.openSettings')" :aria-label="t('home.openSettings')" @click="openSystemSettings('agents')">
|
||||
<SettingsOutline />
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="recentGroups.length" class="home-recent-list">
|
||||
<button v-for="group in recentGroups" :key="group.id" class="home-recent-item" type="button" @click="selectGroup(group.id)">
|
||||
<span class="home-recent-avatar">
|
||||
<ChatbubblesOutline v-if="group.conversationType !== 'direct'" />
|
||||
<img v-else :src="agentLogo(group.directAgentKind)" alt="" />
|
||||
<img v-else :src="agentLogo(group.directAgentKind, theme)" alt="" />
|
||||
</span>
|
||||
<span class="home-recent-copy">
|
||||
<strong>{{ groupName(group) }}</strong>
|
||||
@@ -761,9 +721,6 @@
|
||||
<h2>{{ t('home.agentStatusTitle') }}</h2>
|
||||
<p>{{ t('home.agentStatusSubtitle') }}</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" :title="t('home.manage')" :aria-label="t('home.manage')" @click="openAgentManager">
|
||||
<TerminalOutline />
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="homeAgentPreview.length" class="home-agent-list">
|
||||
<button v-for="agent in homeAgentPreview" :key="agent.kind" type="button" class="home-agent-item" @click="openDirect(agent)">
|
||||
@@ -772,23 +729,13 @@
|
||||
<strong>{{ agent.label }}</strong>
|
||||
<small>{{ providerSummaryLabel(agent) }}</small>
|
||||
</span>
|
||||
<CheckmarkCircleOutline />
|
||||
<ChevronForwardOutline />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="home-panel-empty">
|
||||
<TerminalOutline />
|
||||
<p>{{ t('home.noReadyAgents') }}</p>
|
||||
</div>
|
||||
<footer class="home-panel-footer">
|
||||
<button class="secondary-button compact" type="button" @click="openAgentManager">
|
||||
<SettingsOutline />
|
||||
{{ t('home.manage') }}
|
||||
</button>
|
||||
<button class="secondary-button compact" type="button" @click="openProvider()">
|
||||
<KeyOutline />
|
||||
{{ t('systemSettings.providers') }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -847,13 +794,13 @@
|
||||
<header class="conversation-header" :class="{ 'editing-title': inlineTitleEditing }">
|
||||
<div class="conversation-identity">
|
||||
<div v-if="activeGroup.conversationType === 'direct'" class="conversation-avatar single">
|
||||
<img :src="agentLogo(activeGroup.directAgentKind)" alt="" />
|
||||
<img :src="agentLogo(activeGroup.directAgentKind, theme)" alt="" />
|
||||
</div>
|
||||
<div v-else class="conversation-avatar stack">
|
||||
<img
|
||||
v-for="kind in activeGroup.agentKinds.slice(0, 3)"
|
||||
:key="kind"
|
||||
:src="agentLogo(kind)"
|
||||
:src="agentLogo(kind, theme)"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
@@ -1011,10 +958,10 @@
|
||||
<img
|
||||
v-if="message.role === 'agent'"
|
||||
class="message-avatar"
|
||||
:src="agentLogo(message.agentKind)"
|
||||
:src="agentLogo(message.agentKind, theme)"
|
||||
:alt="agentLabel(message.agentKind)"
|
||||
/>
|
||||
<div class="message-body">
|
||||
<div class="message-body" :class="{ 'has-topic-replies': isTopicRoot(message) }">
|
||||
<div class="message-meta">
|
||||
<strong>{{ message.role === 'user' ? t('conversation.you') : agentLabel(message.agentKind) }}</strong>
|
||||
<time>{{ formatTime(message.createdAt) }}</time>
|
||||
@@ -1066,7 +1013,7 @@
|
||||
:aria-label="t('composer.mentionedAgents')"
|
||||
>
|
||||
<span v-for="kind in messageTargetKinds(message)" :key="kind">
|
||||
<img :src="agentLogo(kind)" alt="" />
|
||||
<img :src="agentLogo(kind, theme)" alt="" />
|
||||
{{ agentLabel(kind) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1091,7 +1038,7 @@
|
||||
<img
|
||||
v-for="kind in topicReplyAgentKinds(message.id)"
|
||||
:key="kind"
|
||||
:src="agentLogo(kind)"
|
||||
:src="agentLogo(kind, theme)"
|
||||
:alt="agentLabel(kind)"
|
||||
/>
|
||||
</span>
|
||||
@@ -1151,14 +1098,14 @@
|
||||
>
|
||||
<header class="run-status-header">
|
||||
<div v-if="activeGroup.conversationType === 'direct'" class="direct-run-indicator" aria-hidden="true">
|
||||
<img :src="agentLogo(activeRun.currentKind || activeGroup.directAgentKind)" alt="" />
|
||||
<img :src="agentLogo(activeRun.currentKind || activeGroup.directAgentKind, theme)" alt="" />
|
||||
<div class="typing-bars"><span /><span /><span /></div>
|
||||
</div>
|
||||
<div v-else class="relay-run-indicator" aria-hidden="true">
|
||||
<img
|
||||
v-for="kind in runTargetKinds.slice(0, 4)"
|
||||
:key="kind"
|
||||
:src="agentLogo(kind)"
|
||||
:src="agentLogo(kind, theme)"
|
||||
alt=""
|
||||
:class="{
|
||||
current: runAgentStatus(kind) === 'running',
|
||||
@@ -1187,7 +1134,7 @@
|
||||
:data-status="runAgentStatus(kind)"
|
||||
:style="{ '--reveal-index': index }"
|
||||
>
|
||||
<img :src="agentLogo(kind)" alt="" />
|
||||
<img :src="agentLogo(kind, theme)" alt="" />
|
||||
<strong>{{ agentLabel(kind) }}</strong>
|
||||
<span class="run-agent-state">
|
||||
<span
|
||||
@@ -1250,7 +1197,7 @@
|
||||
>
|
||||
<img
|
||||
v-if="option.type === 'agent'"
|
||||
:src="agentLogo(option.value)"
|
||||
:src="agentLogo(option.value, theme)"
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
@@ -1294,8 +1241,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="target-row">
|
||||
<span>{{ t('composer.targets') }}</span>
|
||||
<div class="target-row" :aria-label="t('composer.targets')">
|
||||
<div class="target-avatar-stack" role="group" :aria-label="t('composer.targets')">
|
||||
<button
|
||||
v-for="(kind, index) in activeGroup.agentKinds"
|
||||
@@ -1312,7 +1258,7 @@
|
||||
:disabled="Boolean(activeRun) || sending || discussionMode === 'auto'"
|
||||
@click="toggleTarget(kind)"
|
||||
>
|
||||
<img :src="agentLogo(kind)" alt="" />
|
||||
<img :src="agentLogo(kind, theme)" alt="" />
|
||||
<span class="visually-hidden">{{ agentLabel(kind) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1414,7 +1360,7 @@
|
||||
:aria-label="t('composer.mentionedAgents')"
|
||||
>
|
||||
<span v-for="kind in selectedAgentKinds" :key="kind" class="selected-agent-tag">
|
||||
<img :src="agentLogo(kind)" alt="" />
|
||||
<img :src="agentLogo(kind, theme)" alt="" />
|
||||
{{ agentLabel(kind) }}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1450,7 +1396,7 @@
|
||||
type="button"
|
||||
:title="attachmentActionLabel"
|
||||
:aria-label="attachmentActionLabel"
|
||||
:disabled="Boolean(activeRun) || sending || importingAttachment || !composerTargetsReady || composerImageLimit <= composerAttachments.length"
|
||||
:disabled="Boolean(activeRun) || sending || importingAttachment || (composerImageLimit > 0 && composerImageLimit <= composerAttachments.length)"
|
||||
@click="pickImages"
|
||||
>
|
||||
<RefreshOutline v-if="importingAttachment" class="spinning" />
|
||||
@@ -1736,6 +1682,14 @@
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<section v-else-if="modal === 'unlimited-confirm'" class="modal-body confirmation-modal-body">
|
||||
<p>{{ t('composer.unlimitedConfirm') }}</p>
|
||||
<footer class="modal-footer confirmation-modal-footer">
|
||||
<button class="secondary-button" type="button" @click="closeModal">{{ t('common.cancel') }}</button>
|
||||
<button class="primary-button" type="button" @click="confirmUnlimitedRounds">{{ t('common.confirm') }}</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section v-else-if="modal === 'agent-detail' && selectedAgentDetail" class="modal-body agent-detail-body">
|
||||
<div class="agent-detail-hero">
|
||||
<img :src="selectedAgentDetail.logo" :alt="selectedAgentDetail.label" />
|
||||
@@ -1901,7 +1855,8 @@ const NATIVE_PROVIDER_READY_SOURCES = new Set(['native-credential', 'native-auth
|
||||
const COMING_SOON_KNOWLEDGE_BASE_KINDS = new Set(['notion', 'confluence', 'googledrive', 'sharepoint'])
|
||||
const EXTERNAL_PROVIDER_KINDS = new Set(AGENTS.map(agent => agent.kind))
|
||||
const EMPTY_PROVIDER_STATUS = Object.freeze({
|
||||
provider: '', baseUrl: '', model: '', configured: false, encryptionAvailable: true, error: false,
|
||||
provider: '', baseUrl: '', model: '', activePreset: 'official', profiles: {},
|
||||
configured: false, encryptionAvailable: true, error: false,
|
||||
})
|
||||
const installCatalog = ref({ platform: '', agents: [] })
|
||||
const installerState = ref({ taskId: '', kind: '', phase: 'idle', canCancel: false, errorCode: '' })
|
||||
@@ -2278,7 +2233,7 @@ const mergedCatalog = computed(() => AGENTS.map((profile) => {
|
||||
...installedProfile,
|
||||
...detected,
|
||||
label: profile.label,
|
||||
logo: profile.logo,
|
||||
logo: agentLogo(profile.kind, theme.value),
|
||||
providerMode: profile.providerMode,
|
||||
imageLimit: Number(detected.imageAttachmentLimit ?? installedProfile.imageAttachmentLimit ?? profile.imageLimit) || 0,
|
||||
installed: Boolean(installedProfile.installed || detected.installed),
|
||||
@@ -2338,7 +2293,19 @@ const agentDetailSkillSummary = computed(() => {
|
||||
if (!agentDetailSkillItems.value.length) return t('agent.skillsUnavailable')
|
||||
return t('agent.localSkills', { count: agentDetailSkillItems.value.length })
|
||||
})
|
||||
const showSetupGuide = computed(() => onboardingCompleted.value && !snapshot.value.groups.length)
|
||||
const showSetupGuide = computed(() => (
|
||||
onboardingCompleted.value
|
||||
&& !snapshot.value.groups.length
|
||||
&& readyCount.value === 0
|
||||
))
|
||||
const homeWorkspaceSummary = computed(() => (
|
||||
showSetupGuide.value
|
||||
? t('home.setupNeeded')
|
||||
: t('home.workspaceSummary', {
|
||||
agents: readyCount.value,
|
||||
conversations: snapshot.value.groups.length,
|
||||
})
|
||||
))
|
||||
const setupGuideMessage = computed(() => {
|
||||
if (!readyCount.value) return t('setupGuide.detectBody')
|
||||
if (configurableProviderAgents.value.length && !providerConfiguredCount.value) return t('setupGuide.providerBody')
|
||||
@@ -2356,29 +2323,22 @@ const selectedProviderPreset = computed(() => (
|
||||
|| selectedProviderPresets.value[0]
|
||||
|| null
|
||||
))
|
||||
const selectedProviderRuntimeKeys = computed(() => selectedProviderProfile.value.runtimeKeys || [])
|
||||
const providerActivePreset = computed(() => (
|
||||
providerStatus.value.activePreset || inferProviderPreset(selectedProviderKind.value, providerStatus.value)
|
||||
))
|
||||
const selectedProviderProfileStatus = computed(() => (
|
||||
providerProfilesFor(selectedProviderKind.value)[providerForm.preset] || null
|
||||
))
|
||||
const selectedProviderProfileSaved = computed(() => Boolean(selectedProviderProfileStatus.value?.configured))
|
||||
const selectedProviderPresetActive = computed(() => providerActivePreset.value === providerForm.preset)
|
||||
const selectedProviderPresetConfigured = computed(() => (
|
||||
selectedProviderProfileSaved.value
|
||||
|| (providerForm.preset === 'official' && nativeProviderReady(selectedProviderAgent.value))
|
||||
))
|
||||
const providerNativeOfficialMode = computed(() => (
|
||||
selectedProviderAgent.value?.providerMode === 'native' && providerForm.preset === 'official'
|
||||
))
|
||||
const providerNativeStatusPending = computed(() => (
|
||||
providerNativeOfficialMode.value && !hasProviderStatus(selectedProviderKind.value)
|
||||
))
|
||||
const providerNativeStatusError = computed(() => (
|
||||
providerNativeOfficialMode.value
|
||||
&& hasProviderStatus(selectedProviderKind.value)
|
||||
&& providerStatus.value.error
|
||||
))
|
||||
const providerNativeOverrideActive = computed(() => (
|
||||
providerNativeOfficialMode.value
|
||||
&& hasProviderStatus(selectedProviderKind.value)
|
||||
&& !providerStatus.value.error
|
||||
&& providerStatus.value.configured
|
||||
))
|
||||
const providerNativeConfigSelected = computed(() => (
|
||||
providerNativeOfficialMode.value
|
||||
&& hasProviderStatus(selectedProviderKind.value)
|
||||
&& !providerStatus.value.error
|
||||
&& !providerStatus.value.configured
|
||||
providerForm.preset === 'official'
|
||||
&& !selectedProviderProfileSaved.value
|
||||
&& nativeProviderReady(selectedProviderAgent.value)
|
||||
))
|
||||
const roundProgressPercent = computed(() => {
|
||||
const bounded = Math.max(1, Math.min(10, Number(maxRounds.value) || 1))
|
||||
@@ -2394,6 +2354,7 @@ const modalTitle = computed(() => ({
|
||||
settings: settingsIntent.value === 'rename'
|
||||
? t(activeGroup.value?.conversationType === 'direct' ? 'settings.renameDirectTitle' : 'settings.renameGroupTitle')
|
||||
: t('settings.title'),
|
||||
'unlimited-confirm': t('composer.unlimitedConfirmTitle'),
|
||||
'agent-detail': selectedAgentDetail.value?.label || t('systemSettings.openAgentDetailDefault'),
|
||||
})[modal.value] || '')
|
||||
const modalSubtitle = computed(() => ({
|
||||
@@ -2530,6 +2491,23 @@ function providerStatusFor(kind) {
|
||||
return providerStatuses.value[kind] || EMPTY_PROVIDER_STATUS
|
||||
}
|
||||
|
||||
function providerProfilesFor(kind) {
|
||||
const status = providerStatusFor(kind)
|
||||
if (status.profiles && typeof status.profiles === 'object' && !Array.isArray(status.profiles)) {
|
||||
return status.profiles
|
||||
}
|
||||
if (!status.configured) return {}
|
||||
const preset = status.activePreset || inferProviderPreset(kind, status)
|
||||
return {
|
||||
[preset]: {
|
||||
provider: status.provider,
|
||||
baseUrl: status.baseUrl,
|
||||
model: status.model,
|
||||
configured: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hasProviderStatus(kind) {
|
||||
return Object.prototype.hasOwnProperty.call(providerStatuses.value, String(kind || ''))
|
||||
}
|
||||
@@ -2578,6 +2556,20 @@ function providerPresetHint(id) {
|
||||
return t(`provider.presetHint.${id}`)
|
||||
}
|
||||
|
||||
function providerPresetConfigured(presetId) {
|
||||
if (providerProfilesFor(selectedProviderKind.value)[presetId]?.configured) return true
|
||||
return presetId === 'official' && nativeProviderReady(selectedProviderAgent.value)
|
||||
}
|
||||
|
||||
function providerPresetActive(presetId) {
|
||||
return providerActivePreset.value === presetId
|
||||
}
|
||||
|
||||
function providerPresetStateLabel(presetId) {
|
||||
if (providerPresetActive(presetId)) return t('provider.active')
|
||||
return providerPresetConfigured(presetId) ? t('provider.configured') : t('provider.notConfigured')
|
||||
}
|
||||
|
||||
function providerPresetFor(kind, presetId) {
|
||||
const profile = providerProfile(kind)
|
||||
return profile.presets.find(preset => preset.id === presetId) || profile.presets[0] || null
|
||||
@@ -2586,10 +2578,11 @@ function providerPresetFor(kind, presetId) {
|
||||
function fillProviderFormFromPreset(kind, presetId) {
|
||||
const preset = providerPresetFor(kind, presetId)
|
||||
if (!preset) return
|
||||
const saved = providerProfilesFor(kind)[preset.id]
|
||||
providerForm.preset = preset.id
|
||||
providerForm.provider = preset.provider || ''
|
||||
providerForm.baseUrl = preset.baseUrl || ''
|
||||
providerForm.model = preset.model || ''
|
||||
providerForm.provider = saved?.provider || preset.provider || ''
|
||||
providerForm.baseUrl = saved?.baseUrl || preset.baseUrl || ''
|
||||
providerForm.model = saved?.model || preset.model || ''
|
||||
}
|
||||
|
||||
function applyProviderPreset(presetId) {
|
||||
@@ -3941,10 +3934,12 @@ async function cancelInstall() {
|
||||
|
||||
function requestUnlimitedRounds() {
|
||||
if (unlimitedRounds.value) return
|
||||
const confirmed = typeof window.confirm === 'function'
|
||||
? window.confirm(t('composer.unlimitedConfirm'))
|
||||
: true
|
||||
if (confirmed) unlimitedRounds.value = true
|
||||
modal.value = 'unlimited-confirm'
|
||||
}
|
||||
|
||||
function confirmUnlimitedRounds() {
|
||||
unlimitedRounds.value = true
|
||||
closeModal()
|
||||
}
|
||||
|
||||
async function loadProviderStatus(kind, probeEncryption = false) {
|
||||
@@ -3960,6 +3955,17 @@ async function loadProviderStatus(kind, probeEncryption = false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function retryProviderStatus(kind) {
|
||||
const targetKind = String(kind || '')
|
||||
if (!EXTERNAL_PROVIDER_KINDS.has(targetKind) || saving.value) return EMPTY_PROVIDER_STATUS
|
||||
const nextStatuses = { ...providerStatuses.value }
|
||||
delete nextStatuses[targetKind]
|
||||
providerStatuses.value = nextStatuses
|
||||
const status = await loadProviderStatus(targetKind, true)
|
||||
if (selectedProviderKind.value === targetKind && !status.error) syncProviderForm(targetKind)
|
||||
return status
|
||||
}
|
||||
|
||||
async function loadProviderStatuses() {
|
||||
await Promise.all(configurableProviderAgents.value.map(agent => loadProviderStatus(agent.kind)))
|
||||
}
|
||||
@@ -4031,14 +4037,12 @@ async function loadKnowledgeBaseStatuses() {
|
||||
|
||||
function syncProviderForm(kind) {
|
||||
const status = providerStatusFor(kind)
|
||||
const preset = inferProviderPreset(kind, status)
|
||||
if (status.configured) {
|
||||
providerForm.preset = preset
|
||||
providerForm.provider = status.provider || ''
|
||||
providerForm.baseUrl = status.baseUrl || ''
|
||||
providerForm.model = status.model || ''
|
||||
} else {
|
||||
fillProviderFormFromPreset(kind, preset)
|
||||
const preset = status.activePreset || inferProviderPreset(kind, status)
|
||||
fillProviderFormFromPreset(kind, preset)
|
||||
if (status.configured && !providerProfilesFor(kind)[preset]) {
|
||||
providerForm.provider = status.provider || providerForm.provider
|
||||
providerForm.baseUrl = status.baseUrl || providerForm.baseUrl
|
||||
providerForm.model = status.model || providerForm.model
|
||||
}
|
||||
providerForm.apiKey = ''
|
||||
}
|
||||
@@ -4058,6 +4062,26 @@ function openProvider(kind = '') {
|
||||
openSystemSettings('providers', EXTERNAL_PROVIDER_KINDS.has(kind) ? kind : selectedProviderKind.value)
|
||||
}
|
||||
|
||||
async function activateProviderPreset(presetId) {
|
||||
if (saving.value || providerPresetActive(presetId) || !providerPresetConfigured(presetId)) return
|
||||
if (typeof provider.value?.activate !== 'function') {
|
||||
formError.value = t('provider.switchUnavailable')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
const kind = selectedProviderKind.value
|
||||
await provider.value.activate(kind, presetId)
|
||||
await Promise.all([loadProviderStatus(kind), refreshAgents()])
|
||||
syncProviderForm(kind)
|
||||
} catch (error) {
|
||||
formError.value = translateError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
formError.value = ''
|
||||
if (providerNativeOfficialMode.value) return
|
||||
@@ -4073,6 +4097,7 @@ async function saveProvider() {
|
||||
try {
|
||||
const kind = selectedProviderKind.value
|
||||
await provider.value.save(kind, {
|
||||
preset: providerForm.preset,
|
||||
provider: providerForm.provider,
|
||||
baseUrl: providerForm.baseUrl,
|
||||
model: providerForm.model,
|
||||
@@ -4218,63 +4243,32 @@ function knowledgeBaseStatusLabel(source) {
|
||||
return t('knowledgeBase.status.checking')
|
||||
}
|
||||
|
||||
function knowledgeBaseTags(source) {
|
||||
function knowledgeBaseFacts(source) {
|
||||
if (!source) return []
|
||||
if (knowledgeBaseComingSoon(source)) {
|
||||
return [
|
||||
{ label: knowledgeBaseModeLabel(source), tone: 'checking' },
|
||||
{ label: t('knowledgeBase.tag.comingSoon'), tone: 'checking' },
|
||||
]
|
||||
}
|
||||
if (knowledgeBasePending(source)) {
|
||||
return [{ label: t('knowledgeBase.tag.checking'), tone: 'checking' }]
|
||||
}
|
||||
if (source.probeState === 'unknown') {
|
||||
return [{ label: t('knowledgeBase.status.unknown'), tone: 'checking' }]
|
||||
}
|
||||
const tags = [{
|
||||
label: knowledgeBaseModeLabel(source),
|
||||
tone: knowledgeBaseConfigured(source) ? 'connected' : 'checking',
|
||||
}]
|
||||
if (source.probeState === 'error') {
|
||||
tags.push({
|
||||
label: t('knowledgeBase.status.error'),
|
||||
tone: 'warning',
|
||||
})
|
||||
return tags
|
||||
}
|
||||
if (source.accessMode === 'cli') {
|
||||
tags.push({
|
||||
label: source.installed ? t('knowledgeBase.tag.cliInstalled') : t('knowledgeBase.tag.cliMissing'),
|
||||
tone: source.installed ? 'connected' : 'warning',
|
||||
})
|
||||
tags.push({
|
||||
label: source.loginState === 'ready' ? t('knowledgeBase.tag.loggedIn') : t('knowledgeBase.tag.needsLogin'),
|
||||
tone: source.loginState === 'ready' ? 'connected' : 'warning',
|
||||
})
|
||||
} else if (source.accessMode === 'vault') {
|
||||
tags.push({
|
||||
label: source.installed ? t('knowledgeBase.tag.appInstalled') : t('knowledgeBase.tag.appMissing'),
|
||||
tone: source.installed ? 'connected' : 'warning',
|
||||
})
|
||||
tags.push({
|
||||
label: source.vaultPath ? t('knowledgeBase.tag.directoryReady') : t('knowledgeBase.tag.directoryMissing'),
|
||||
tone: source.vaultPath ? 'connected' : 'warning',
|
||||
})
|
||||
}
|
||||
tags.push({
|
||||
label: knowledgeBaseCanRead(source)
|
||||
? t('knowledgeBase.tag.readEnabled')
|
||||
: t('knowledgeBase.tag.readMissing'),
|
||||
tone: knowledgeBaseCanRead(source) ? 'connected' : 'warning',
|
||||
})
|
||||
tags.push({
|
||||
label: knowledgeBaseCanWrite(source)
|
||||
? t('knowledgeBase.tag.writeEnabled')
|
||||
: t('knowledgeBase.tag.writeMissing'),
|
||||
tone: knowledgeBaseCanWrite(source) ? 'connected' : source.accessMode === 'cli' ? 'checking' : 'warning',
|
||||
})
|
||||
return tags
|
||||
const pending = knowledgeBasePending(source)
|
||||
const unresolved = source.probeState === 'unknown' || source.probeState === 'error'
|
||||
const canRead = knowledgeBaseCanRead(source)
|
||||
const canWrite = knowledgeBaseCanWrite(source)
|
||||
return [
|
||||
{
|
||||
key: 'connection',
|
||||
label: t('knowledgeBase.fact.connection'),
|
||||
value: knowledgeBaseModeLabel(source),
|
||||
tone: 'neutral',
|
||||
},
|
||||
{
|
||||
key: 'read',
|
||||
label: t('knowledgeBase.fact.read'),
|
||||
value: pending ? t('knowledgeBase.status.checking') : canRead ? t('knowledgeBase.tag.readEnabled') : t('knowledgeBase.tag.readMissing'),
|
||||
tone: pending || unresolved ? 'checking' : canRead ? 'connected' : 'warning',
|
||||
},
|
||||
{
|
||||
key: 'write',
|
||||
label: t('knowledgeBase.fact.write'),
|
||||
value: pending ? t('knowledgeBase.status.checking') : canWrite ? t('knowledgeBase.tag.writeEnabled') : t('knowledgeBase.tag.writeMissing'),
|
||||
tone: pending || unresolved ? 'checking' : canWrite ? 'connected' : source.accessMode === 'cli' ? 'checking' : 'warning',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function knowledgeBaseHint(source) {
|
||||
@@ -4411,10 +4405,12 @@ async function removeProvider() {
|
||||
saving.value = true
|
||||
try {
|
||||
const kind = selectedProviderKind.value
|
||||
await provider.value.delete(kind)
|
||||
const preset = providerForm.preset
|
||||
await provider.value.delete(kind, preset)
|
||||
await Promise.all([loadProviderStatus(kind), refreshAgents()])
|
||||
providerRemoveArmed.value = false
|
||||
syncProviderForm(kind)
|
||||
fillProviderFormFromPreset(kind, preset)
|
||||
providerForm.apiKey = ''
|
||||
} catch (error) {
|
||||
formError.value = translateError(error)
|
||||
} finally {
|
||||
@@ -4608,14 +4604,6 @@ function handleWindowKeydown(event) {
|
||||
sidebarDeleteGroupId.value = ''
|
||||
return
|
||||
}
|
||||
if (roundSettingsOpen.value) {
|
||||
roundSettingsOpen.value = false
|
||||
return
|
||||
}
|
||||
if (skillMenuOpen.value) {
|
||||
skillMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
if (onboardingVisible.value) {
|
||||
completeOnboarding()
|
||||
return
|
||||
@@ -4624,7 +4612,18 @@ function handleWindowKeydown(event) {
|
||||
deleteArmed.value = false
|
||||
return
|
||||
}
|
||||
if (modal.value) closeModal()
|
||||
if (modal.value) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
if (roundSettingsOpen.value) {
|
||||
roundSettingsOpen.value = false
|
||||
return
|
||||
}
|
||||
if (skillMenuOpen.value) {
|
||||
skillMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowPointerDown(event) {
|
||||
|
||||
@@ -111,6 +111,8 @@ function createBridge() {
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
})),
|
||||
@@ -119,11 +121,32 @@ function createBridge() {
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
})),
|
||||
save: vi.fn(async (kind, input) => ({ kind, ...input, configured: true, encryptionAvailable: true })),
|
||||
delete: vi.fn(async kind => ({ kind, configured: false, encryptionAvailable: true })),
|
||||
activate: vi.fn(async (kind, preset) => ({
|
||||
kind,
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: preset,
|
||||
profiles: {},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
})),
|
||||
delete: vi.fn(async kind => ({
|
||||
kind,
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
})),
|
||||
}
|
||||
const localKnowledgeBase = {
|
||||
status: vi.fn(async () => ([
|
||||
@@ -402,9 +425,7 @@ describe('RoundRelay workbench', () => {
|
||||
.toEqual(AGENTS.map(agent => agent.kind).sort())
|
||||
expect(bridge.localAgentProvider.probe).not.toHaveBeenCalled()
|
||||
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('.home-overview-item')[1].get('strong').text()).toBe('0')
|
||||
expect(wrapper.findAll('.home-overview-item')[2].get('strong').text()).toBe('1')
|
||||
expect(wrapper.findAll('.home-overview-item')[2].get('small').text()).toBe('3 local sources')
|
||||
expect(wrapper.find('.home-overview-item').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
expect(wrapper.get('.system-settings-page').exists()).toBe(true)
|
||||
@@ -416,6 +437,7 @@ describe('RoundRelay workbench', () => {
|
||||
expect(bridge.localAgentProvider.status).toHaveBeenCalledTimes(AGENTS.length * 2)
|
||||
expect(bridge.localAgentProvider.probe).toHaveBeenCalledTimes(1)
|
||||
expect(bridge.localAgentProvider.probe).toHaveBeenCalledWith('codex')
|
||||
expect(wrapper.get('.provider-summary-count').text()).toContain(`0 ready of ${AGENTS.length}`)
|
||||
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.findAll('.settings-tabs button')[0].trigger('click')
|
||||
@@ -425,12 +447,25 @@ describe('RoundRelay workbench', () => {
|
||||
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
|
||||
await flushPromises()
|
||||
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('1 ready, 3 total')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers three Provider sources for every Agent and clears the API key when switching sources', async () => {
|
||||
const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
|
||||
expect(styles).toMatch(/\.provider-source-options small\s*\{[^}]*-webkit-line-clamp:\s*2;/s)
|
||||
expect(styles).toMatch(/\.provider-source-options\s*\{[^}]*display:\s*flex;[^}]*gap:\s*6px;/s)
|
||||
expect(styles).toMatch(/\.provider-source-options button\s*\{[^}]*border:\s*0;[^}]*background:\s*color-mix/s)
|
||||
expect(styles).toMatch(/\.provider-editor-tags span,[\s\S]*\.provider-profile-summary code\s*\{[^}]*border:\s*0;/s)
|
||||
expect(styles).toMatch(/\.provider-settings-panel\s*\{[^}]*--provider-success:\s*#4f7564;/s)
|
||||
expect(styles).toMatch(/\.provider-external-fields\s*\{[^}]*"name model"[^}]*"url url"[^}]*"key key";/s)
|
||||
const narrowProviderRules = styles.slice(
|
||||
styles.indexOf('@media (max-width: 920px)'),
|
||||
styles.indexOf('@media (max-width: 760px)'),
|
||||
)
|
||||
expect(narrowProviderRules).toMatch(/\.provider-settings-panel\s*\{[^}]*grid-template-columns:\s*1fr;/s)
|
||||
expect(narrowProviderRules).toMatch(/\.provider-agent-list\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);/s)
|
||||
expect(narrowProviderRules).toMatch(/\.provider-editor\s*\{[^}]*width:\s*100%;[^}]*overflow:\s*visible;/s)
|
||||
expect(narrowProviderRules).toMatch(/\.provider-source-heading small\s*\{[^}]*white-space:\s*normal;/s)
|
||||
|
||||
const { wrapper } = await mountApp()
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
@@ -438,6 +473,10 @@ describe('RoundRelay workbench', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.provider-agent-list button')).toHaveLength(AGENTS.length)
|
||||
expect(wrapper.find('.provider-agent-mode').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.provider-agent-list button').every(button => (
|
||||
button.findAll('.provider-agent-copy small').length === 1
|
||||
))).toBe(true)
|
||||
for (const agent of AGENTS) {
|
||||
const agentButton = wrapper.findAll('.provider-agent-list button')
|
||||
.find(button => button.text().includes(agent.label))
|
||||
@@ -447,12 +486,14 @@ describe('RoundRelay workbench', () => {
|
||||
const sources = wrapper.findAll('.provider-source-options button')
|
||||
expect(sources).toHaveLength(3)
|
||||
expect(sources.map(button => button.get('strong').text())).toEqual(['Official', 'OpenRouter', 'Custom'])
|
||||
expect(wrapper.get('.provider-source-heading small').text()).toContain('official')
|
||||
}
|
||||
|
||||
const apiKey = wrapper.get('.provider-editor input[type="password"]')
|
||||
await apiKey.setValue('secret-that-must-not-carry-over')
|
||||
await wrapper.findAll('.provider-source-options button')[1].trigger('click')
|
||||
expect(wrapper.get('.provider-editor input[type="password"]').element.value).toBe('')
|
||||
expect(wrapper.get('.provider-source-heading small').text()).toContain('OpenRouter')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -461,7 +502,6 @@ describe('RoundRelay workbench', () => {
|
||||
state.agents[0].availabilitySource = 'native-credential'
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.home-overview-item')[1].get('strong').text()).toBe('1')
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
|
||||
await flushPromises()
|
||||
@@ -470,8 +510,9 @@ describe('RoundRelay workbench', () => {
|
||||
.find(button => button.text().includes('Codex'))
|
||||
expect(codexProvider.text()).toContain('Native config ready')
|
||||
expect(codexProvider.find('svg.ready').exists()).toBe(true)
|
||||
expect(wrapper.get('.provider-status').text()).toContain('Native config ready')
|
||||
expect(wrapper.get('.provider-native-detected-note').text()).toContain('Meldwork-managed override')
|
||||
expect(wrapper.get('.provider-editor-tags').text()).toContain('Native config ready')
|
||||
expect(wrapper.get('.provider-profile-summary').text()).toContain('Native config ready')
|
||||
expect(wrapper.find('.provider-inline-warning').exists()).toBe(false)
|
||||
expect(wrapper.find('.provider-editor .danger-button').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -483,10 +524,12 @@ describe('RoundRelay workbench', () => {
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
})
|
||||
const { wrapper } = await mountApp(({ state, bridge }) => {
|
||||
const { wrapper, bridge } = await mountApp(({ state, bridge }) => {
|
||||
state.agents.push({
|
||||
kind: 'kimi',
|
||||
installed: true,
|
||||
@@ -503,7 +546,6 @@ describe('RoundRelay workbench', () => {
|
||||
))
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.home-overview-item')[1].get('strong').text()).toBe('0')
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
@@ -515,13 +557,9 @@ describe('RoundRelay workbench', () => {
|
||||
await kimiProvider.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.get('.provider-status').text()).toContain('Checking')
|
||||
expect(wrapper.get('.provider-status').classes()).toContain('checking')
|
||||
expect(wrapper.get('.provider-status').attributes('role')).toBe('status')
|
||||
expect(wrapper.get('.provider-editor').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.get('.provider-native-card').text()).toContain('Checking saved overrides')
|
||||
expect(wrapper.get('.provider-native-footer-note').text()).toContain('Checking')
|
||||
expect(wrapper.find('.provider-native-detected-note').exists()).toBe(false)
|
||||
expect(wrapper.get('.provider-editor-tags').text()).toContain('Checking')
|
||||
expect(wrapper.find('.provider-inline-warning').exists()).toBe(false)
|
||||
|
||||
kimiStatus.reject(Object.assign(new Error('Provider status unavailable'), {
|
||||
code: 'PROVIDER_STATUS_UNAVAILABLE',
|
||||
@@ -531,12 +569,16 @@ describe('RoundRelay workbench', () => {
|
||||
expect(kimiProvider.text()).toContain('Status unavailable')
|
||||
expect(kimiProvider.text()).not.toContain('Native config ready')
|
||||
expect(kimiProvider.find('svg.ready').exists()).toBe(false)
|
||||
expect(wrapper.get('.provider-status').text()).toContain('Status unavailable')
|
||||
expect(wrapper.get('.provider-editor').attributes('aria-busy')).toBe('false')
|
||||
expect(wrapper.get('.provider-native-card').text()).toContain('Provider status could not be verified')
|
||||
expect(wrapper.get('.provider-native-footer-note.warning').text()).toContain('Provider status is unavailable')
|
||||
expect(wrapper.find('.provider-native-detected-note').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Native CLI configuration is selected')
|
||||
expect(wrapper.get('.provider-inline-warning').text()).toContain('Status unavailable')
|
||||
expect(wrapper.get('.provider-inline-warning button').text()).toContain('Retry')
|
||||
|
||||
bridge.localAgentProvider.probe.mockResolvedValue(emptyStatus('kimi'))
|
||||
await wrapper.get('.provider-inline-warning button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bridge.localAgentProvider.probe).toHaveBeenLastCalledWith('kimi')
|
||||
expect(wrapper.get('.provider-profile-summary').text()).toContain('Native config ready')
|
||||
expect(wrapper.find('.provider-inline-warning').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -545,7 +587,6 @@ describe('RoundRelay workbench', () => {
|
||||
state.agents[0].availabilitySource = 'verified-run'
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.home-overview-item')[1].get('strong').text()).toBe('1')
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
|
||||
await flushPromises()
|
||||
@@ -557,14 +598,29 @@ describe('RoundRelay workbench', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a native Agent override active until the user confirms removal twice', async () => {
|
||||
let hasOverride = true
|
||||
const providerStatus = kind => (kind === 'kimi' && hasOverride
|
||||
it('removes only the selected Provider profile after two-step confirmation', async () => {
|
||||
let openrouterSaved = true
|
||||
const providerStatus = kind => (kind === 'kimi' && openrouterSaved
|
||||
? {
|
||||
kind,
|
||||
provider: 'OpenRouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'moonshotai/kimi-k2',
|
||||
activePreset: 'openrouter',
|
||||
profiles: {
|
||||
openrouter: {
|
||||
provider: 'OpenRouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'moonshotai/kimi-k2',
|
||||
configured: true,
|
||||
},
|
||||
custom: {
|
||||
provider: 'Private gateway',
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
model: 'kimi-private',
|
||||
configured: true,
|
||||
},
|
||||
},
|
||||
configured: true,
|
||||
encryptionAvailable: true,
|
||||
}
|
||||
@@ -573,6 +629,15 @@ describe('RoundRelay workbench', () => {
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {
|
||||
custom: {
|
||||
provider: 'Private gateway',
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
model: 'kimi-private',
|
||||
configured: true,
|
||||
},
|
||||
},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
})
|
||||
@@ -587,8 +652,8 @@ describe('RoundRelay workbench', () => {
|
||||
})
|
||||
desktopBridge.localAgentProvider.status.mockImplementation(async kind => providerStatus(kind))
|
||||
desktopBridge.localAgentProvider.probe.mockImplementation(async kind => providerStatus(kind))
|
||||
desktopBridge.localAgentProvider.delete.mockImplementation(async kind => {
|
||||
if (kind === 'kimi') hasOverride = false
|
||||
desktopBridge.localAgentProvider.delete.mockImplementation(async (kind, preset) => {
|
||||
if (kind === 'kimi' && preset === 'openrouter') openrouterSaved = false
|
||||
return providerStatus(kind)
|
||||
})
|
||||
})
|
||||
@@ -599,25 +664,81 @@ describe('RoundRelay workbench', () => {
|
||||
.find(button => button.text().includes('Kimi Code'))
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.provider-source-options button')[0].trigger('click')
|
||||
|
||||
expect(bridge.localAgentProvider.delete).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.provider-native-card').text()).toContain('Meldwork override is still active')
|
||||
expect(wrapper.get('.provider-native-card').text()).toContain('Selecting Official does not remove')
|
||||
expect(wrapper.get('.provider-native-footer-note.warning').text()).toContain('Remove the saved override')
|
||||
expect(wrapper.find('.provider-editor-footer .primary-button').exists()).toBe(false)
|
||||
expect(wrapper.get('.provider-profile-summary').text()).toContain('OpenRouter')
|
||||
|
||||
await wrapper.get('.provider-editor-footer .danger-button').trigger('click')
|
||||
expect(bridge.localAgentProvider.delete).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.provider-editor-footer .danger-button').text()).toContain('Confirm return to native')
|
||||
expect(wrapper.get('.provider-editor-footer .danger-button').text()).toContain('Confirm removal')
|
||||
|
||||
await wrapper.get('.provider-editor-footer .danger-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bridge.localAgentProvider.delete).toHaveBeenCalledWith('kimi')
|
||||
expect(bridge.localAgentProvider.delete).toHaveBeenCalledWith('kimi', 'openrouter')
|
||||
expect(wrapper.find('.provider-editor-footer .danger-button').exists()).toBe(false)
|
||||
expect(wrapper.get('.provider-native-card').text()).toContain('Use the Agent CLI configuration')
|
||||
expect(wrapper.get('.provider-native-footer-note').text()).toContain('Native CLI configuration is selected')
|
||||
expect(wrapper.get('.provider-native-card').text()).not.toContain('override is still active')
|
||||
await wrapper.findAll('.provider-source-options button')[2].trigger('click')
|
||||
expect(wrapper.get('.provider-profile-summary').text()).toContain('Private gateway')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('switches between configured Provider profiles for one Agent', async () => {
|
||||
let activePreset = 'openrouter'
|
||||
const profiles = {
|
||||
openrouter: {
|
||||
provider: 'OpenRouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
configured: true,
|
||||
},
|
||||
custom: {
|
||||
provider: 'Private gateway',
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
model: 'private-model',
|
||||
configured: true,
|
||||
},
|
||||
}
|
||||
const status = kind => ({
|
||||
kind,
|
||||
...profiles[activePreset],
|
||||
activePreset,
|
||||
profiles,
|
||||
configured: true,
|
||||
encryptionAvailable: true,
|
||||
})
|
||||
const { wrapper, bridge } = await mountApp(({ bridge: desktopBridge }) => {
|
||||
desktopBridge.localAgentProvider.status.mockImplementation(async kind => (
|
||||
kind === 'hermes' ? status(kind) : {
|
||||
kind,
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
configured: false,
|
||||
encryptionAvailable: true,
|
||||
}
|
||||
))
|
||||
desktopBridge.localAgentProvider.probe.mockImplementation(desktopBridge.localAgentProvider.status)
|
||||
desktopBridge.localAgentProvider.activate.mockImplementation(async (kind, preset) => {
|
||||
activePreset = preset
|
||||
return status(kind)
|
||||
})
|
||||
})
|
||||
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
|
||||
await wrapper.findAll('.provider-agent-list button')
|
||||
.find(button => button.text().includes('Hermes'))
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('.provider-source-options button')[2].trigger('click')
|
||||
expect(wrapper.get('.provider-profile-summary').text()).toContain('Private gateway')
|
||||
expect(wrapper.get('.provider-activate-button').text()).toContain('Use Custom')
|
||||
|
||||
await wrapper.get('.provider-activate-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bridge.localAgentProvider.activate).toHaveBeenCalledWith('hermes', 'custom')
|
||||
expect(wrapper.get('.provider-editor-tags').text()).toContain('Custom active')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -680,7 +801,6 @@ describe('RoundRelay workbench', () => {
|
||||
bridge.localAgentProvider.probe.mockImplementation(bridge.localAgentProvider.status)
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.home-overview-item')[1].get('strong').text()).toBe('1')
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
await wrapper.findAll('.settings-tabs button')[1].trigger('click')
|
||||
await wrapper.findAll('.provider-agent-list button')
|
||||
@@ -690,7 +810,8 @@ describe('RoundRelay workbench', () => {
|
||||
|
||||
expect(wrapper.find('.provider-agent-list svg.ready').exists()).toBe(true)
|
||||
expect(wrapper.get('.provider-editor-header h2').text()).toContain('Hermes')
|
||||
expect(wrapper.get('.provider-status').text()).toContain('Configured')
|
||||
expect(wrapper.get('.provider-editor-tags').text()).toContain('Configured')
|
||||
expect(wrapper.get('.provider-profile-summary').text()).toContain('Local gateway')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -698,7 +819,19 @@ describe('RoundRelay workbench', () => {
|
||||
const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
|
||||
expect(styles).toMatch(/\.knowledge-base-panel\s*\{[^}]*align-content:\s*start;/s)
|
||||
expect(styles).toMatch(/\.knowledge-base-grid\s*\{[^}]*align-content:\s*start;/s)
|
||||
expect(styles).toMatch(/\.knowledge-base-card\.planned\s*\{/)
|
||||
expect(styles).toMatch(/\.knowledge-base-card-header\s*\{[^}]*grid-template-columns:\s*38px minmax\(0, 1fr\);/s)
|
||||
expect(styles).toMatch(/\.knowledge-base-status\s*\{[^}]*grid-column:\s*2;[^}]*white-space:\s*normal;/s)
|
||||
expect(styles).toMatch(/\.knowledge-base-facts\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1\.05fr\) minmax\(0, 0\.95fr\) minmax\(0, 1\.2fr\);/s)
|
||||
expect(styles).toMatch(/\.knowledge-base-facts dd\s*\{[^}]*text-overflow:\s*clip;[^}]*white-space:\s*normal;/s)
|
||||
expect(styles).toMatch(/\.knowledge-base-planned-list\s*\{/)
|
||||
expect(styles).not.toMatch(/\.knowledge-base-tag\s*\{/)
|
||||
for (const [filename, source] of [
|
||||
['feishu.svg', 'https://open.feishu.cn/'],
|
||||
['dingtalk.svg', 'https://www.dingtalk.com/'],
|
||||
['obsidian.svg', 'https://obsidian.md/brand'],
|
||||
]) {
|
||||
expect(readFileSync(resolve(process.cwd(), `public/knowledge-base-logos/${filename}`), 'utf8')).toContain(`Source: ${source}`)
|
||||
}
|
||||
|
||||
const { wrapper, bridge } = await mountApp()
|
||||
|
||||
@@ -709,7 +842,7 @@ describe('RoundRelay workbench', () => {
|
||||
|
||||
expect(bridge.localKnowledgeBase.status).toHaveBeenCalled()
|
||||
const panelText = wrapper.get('.knowledge-base-panel').text()
|
||||
expect(panelText).toContain('Knowledge sources')
|
||||
expect(panelText).toContain('Knowledge bases')
|
||||
expect(panelText).toContain('Feishu Docs')
|
||||
expect(panelText).toContain('DingTalk Docs')
|
||||
expect(panelText).toContain('Obsidian')
|
||||
@@ -720,27 +853,28 @@ describe('RoundRelay workbench', () => {
|
||||
|
||||
const sourceGroups = wrapper.findAll('.knowledge-base-group')
|
||||
expect(sourceGroups).toHaveLength(2)
|
||||
expect(sourceGroups[0].get('h3').text()).toBe('Local sources')
|
||||
expect(sourceGroups[0].get('h3').text()).toBe('Local connections')
|
||||
expect(sourceGroups[0].findAll('.knowledge-base-card')).toHaveLength(3)
|
||||
expect(sourceGroups[1].get('h3').text()).toBe('Coming soon')
|
||||
expect(sourceGroups[1].findAll('.knowledge-base-card')).toHaveLength(4)
|
||||
expect(sourceGroups[1].findAll('.knowledge-base-card.planned')).toHaveLength(4)
|
||||
expect(wrapper.get('.knowledge-base-panel .manager-toolbar').text()).toContain('1 ready, 3 total')
|
||||
expect(sourceGroups[1].text()).toContain('Reference links only')
|
||||
expect(sourceGroups[1].findAll('.knowledge-base-planned-item')).toHaveLength(4)
|
||||
expect(sourceGroups[1].findAll('.knowledge-base-card')).toHaveLength(0)
|
||||
expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('1 ready, 3 total')
|
||||
expect(wrapper.get('.knowledge-base-panel').attributes('aria-busy')).toBe('false')
|
||||
expect(wrapper.get('.knowledge-base-panel .manager-toolbar').attributes('role')).toBe('status')
|
||||
expect(wrapper.get('.knowledge-base-ready-summary').attributes('role')).toBe('status')
|
||||
|
||||
for (const label of ['Notion', 'Confluence', 'Google Drive', 'SharePoint']) {
|
||||
const plannedCard = wrapper.findAll('.knowledge-base-card')
|
||||
.find(card => card.text().includes(label))
|
||||
expect(plannedCard.get('.knowledge-base-status').text()).toContain('Coming soon')
|
||||
expect(plannedCard.text()).toContain('Connection is not available in this version')
|
||||
expect(plannedCard.get('.knowledge-base-actions button').text()).toContain('View official docs')
|
||||
expect(plannedCard.text()).not.toContain('Not configured')
|
||||
const plannedItem = wrapper.findAll('.knowledge-base-planned-item')
|
||||
.find(item => item.text().includes(label))
|
||||
expect(plannedItem.get('.knowledge-base-planned-status').text()).toContain('Coming soon')
|
||||
expect(plannedItem.get('.knowledge-base-doc-link').text()).toContain('View official docs')
|
||||
expect(plannedItem.text()).not.toContain('Not configured')
|
||||
}
|
||||
|
||||
const notionCard = wrapper.findAll('.knowledge-base-card')
|
||||
.find(card => card.text().includes('Notion'))
|
||||
await notionCard.get('.knowledge-base-actions button').trigger('click')
|
||||
const notionItem = wrapper.findAll('.knowledge-base-planned-item')
|
||||
.find(item => item.text().includes('Notion'))
|
||||
expect(notionItem.text()).toContain('Pages and databases')
|
||||
await notionItem.get('.knowledge-base-doc-link').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bridge.localKnowledgeBase.openGuide).toHaveBeenCalledWith('notion', 'install')
|
||||
expect(bridge.localKnowledgeBase.status).toHaveBeenCalledTimes(2)
|
||||
@@ -798,14 +932,17 @@ describe('RoundRelay workbench', () => {
|
||||
await wrapper.findAll('.settings-tabs button')[2].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.knowledge-base-panel .manager-toolbar').text()).toContain('2 ready, 3 total')
|
||||
expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('2 ready, 3 total')
|
||||
for (const label of ['Feishu Docs', 'DingTalk Docs']) {
|
||||
const card = wrapper.findAll('.knowledge-base-card')
|
||||
.find(item => item.text().includes(label))
|
||||
expect(card.get('.knowledge-base-status').text()).toContain('Ready')
|
||||
expect(card.get('.knowledge-base-tags').text()).toContain('Read enabled')
|
||||
expect(card.get('.knowledge-base-tags').text()).toContain('Write not verified')
|
||||
expect(card.get('.knowledge-base-tags').text()).not.toContain('Write enabled')
|
||||
const facts = card.findAll('.knowledge-base-facts > div')
|
||||
expect(facts).toHaveLength(3)
|
||||
expect(facts[0].text()).toContain('ConnectionOfficial CLI')
|
||||
expect(facts[1].text()).toContain('ReadRead enabled')
|
||||
expect(facts[2].text()).toContain('WriteWrite not verified')
|
||||
expect(facts[2].text()).not.toContain('Write enabled')
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -825,32 +962,36 @@ describe('RoundRelay workbench', () => {
|
||||
expect(localCards).toHaveLength(3)
|
||||
for (const card of localCards) {
|
||||
expect(card.get('.knowledge-base-status').text()).toContain('Checking')
|
||||
expect(card.get('.knowledge-base-tags').text()).toBe('Checking')
|
||||
const facts = card.findAll('.knowledge-base-facts dd')
|
||||
expect(facts).toHaveLength(3)
|
||||
expect(facts[1].text()).toBe('Checking')
|
||||
expect(facts[2].text()).toBe('Checking')
|
||||
expect(card.get('.knowledge-base-hint').text()).toContain('Checking local installation')
|
||||
expect(card.get('.knowledge-base-actions button').text()).toContain('Checking')
|
||||
expect(card.get('.knowledge-base-actions button').attributes()).toHaveProperty('disabled')
|
||||
expect(card.text()).not.toMatch(/CLI not installed|CLI missing|Obsidian not installed|App missing|Vault not selected/)
|
||||
}
|
||||
expect(wrapper.get('.knowledge-base-panel').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.get('.knowledge-base-panel .manager-toolbar').text()).toBe('Checking')
|
||||
expect(wrapper.get('.knowledge-base-ready-summary').text()).toBe('Checking')
|
||||
|
||||
const plannedCards = sourceGroups[1].findAll('.knowledge-base-card')
|
||||
expect(plannedCards).toHaveLength(4)
|
||||
for (const card of plannedCards) {
|
||||
expect(card.get('.knowledge-base-status').text()).toContain('Coming soon')
|
||||
expect(card.get('.knowledge-base-actions button').text()).toContain('View official docs')
|
||||
expect(card.get('.knowledge-base-actions button').attributes()).not.toHaveProperty('disabled')
|
||||
const plannedItems = sourceGroups[1].findAll('.knowledge-base-planned-item')
|
||||
expect(plannedItems).toHaveLength(4)
|
||||
for (const item of plannedItems) {
|
||||
expect(item.get('.knowledge-base-planned-status').text()).toContain('Coming soon')
|
||||
expect(item.get('.knowledge-base-doc-link').text()).toContain('View official docs')
|
||||
expect(item.get('.knowledge-base-doc-link').attributes()).not.toHaveProperty('disabled')
|
||||
}
|
||||
|
||||
statusResult.resolve([])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.knowledge-base-panel').attributes('aria-busy')).toBe('false')
|
||||
expect(wrapper.get('.knowledge-base-panel .manager-toolbar').text()).toContain('0 ready, 3 total')
|
||||
expect(wrapper.get('.knowledge-base-ready-summary').text()).toContain('0 ready, 3 total')
|
||||
const unknownCards = wrapper.findAll('.knowledge-base-group')[0].findAll('.knowledge-base-card')
|
||||
for (const card of unknownCards) {
|
||||
expect(card.get('.knowledge-base-status').text()).toContain('Could not verify')
|
||||
expect(card.get('.knowledge-base-tags').text()).toBe('Could not verify')
|
||||
expect(card.get('.knowledge-base-facts').text()).toContain('Read not verified')
|
||||
expect(card.get('.knowledge-base-facts').text()).toContain('Write not verified')
|
||||
expect(card.get('.knowledge-base-hint').text()).toContain('No local status was returned')
|
||||
expect(card.get('.knowledge-base-actions button').text()).toContain('Recheck')
|
||||
expect(card.get('.knowledge-base-actions button').attributes()).not.toHaveProperty('disabled')
|
||||
@@ -890,13 +1031,22 @@ describe('RoundRelay workbench', () => {
|
||||
const { wrapper } = await mountApp()
|
||||
|
||||
expect(wrapper.get('.home-dashboard-header h1').text()).toBe('Meldwork workspace')
|
||||
expect(wrapper.get('.home-workspace-state').text()).toContain('2 Agents ready')
|
||||
expect(wrapper.get('.home-panel-header h2').text()).toBe('Continue working')
|
||||
expect(wrapper.findAll('.home-panel-header h2')[1].text()).toBe('Start with an Agent')
|
||||
expect(wrapper.find('.home-overview-grid').exists()).toBe(false)
|
||||
expect(wrapper.find('.setup-guide').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.agent-card')).toHaveLength(0)
|
||||
expect(wrapper.get('.brand-button img').attributes('src')).toBe('./logos/meldwork-mark-v3.svg')
|
||||
expect(wrapper.get('.brand-button').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.get('.sidebar-settings-entry').attributes()).not.toHaveProperty('aria-current')
|
||||
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
expect(wrapper.findAll('.agent-card')).toHaveLength(AGENTS.length)
|
||||
expect(wrapper.get('.system-settings-header h1').text()).toBe('Settings')
|
||||
expect(wrapper.get('.system-settings-header p').text()).toContain('knowledge bases')
|
||||
expect(wrapper.get('.sidebar-settings-entry').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.get('.brand-button').attributes()).not.toHaveProperty('aria-current')
|
||||
|
||||
const controls = wrapper.findAll('.sidebar-footer-actions button')
|
||||
await controls[0].trigger('click')
|
||||
@@ -909,6 +1059,21 @@ describe('RoundRelay workbench', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows setup guidance only when no local Agent is ready', async () => {
|
||||
const { wrapper } = await mountApp(({ state }) => {
|
||||
state.agents = state.agents.map(agent => ({
|
||||
...agent,
|
||||
available: false,
|
||||
credentialState: 'missing',
|
||||
}))
|
||||
})
|
||||
|
||||
expect(wrapper.get('.home-workspace-state').classes()).toContain('attention')
|
||||
expect(wrapper.get('.home-workspace-state').text()).toContain('needs attention')
|
||||
expect(wrapper.get('.setup-guide').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('returns to the workspace home from both conversations and Settings', async () => {
|
||||
const { wrapper } = await mountApp(({ state }) => {
|
||||
state.groups.push({
|
||||
@@ -929,9 +1094,11 @@ describe('RoundRelay workbench', () => {
|
||||
await wrapper.get('.brand-button').trigger('click')
|
||||
expect(wrapper.find('.home-dashboard').exists()).toBe(true)
|
||||
expect(wrapper.find('.conversation-pane').exists()).toBe(false)
|
||||
expect(wrapper.get('.brand-button').attributes('aria-current')).toBe('page')
|
||||
|
||||
await wrapper.get('.sidebar-settings-entry').trigger('click')
|
||||
expect(wrapper.find('.system-settings-page').exists()).toBe(true)
|
||||
expect(wrapper.get('.sidebar-settings-entry').attributes('aria-current')).toBe('page')
|
||||
await wrapper.get('.brand-button').trigger('click')
|
||||
expect(wrapper.find('.home-dashboard').exists()).toBe(true)
|
||||
expect(wrapper.find('.system-settings-page').exists()).toBe(false)
|
||||
@@ -996,16 +1163,22 @@ describe('RoundRelay workbench', () => {
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/App.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain('--accent: #d45f52;')
|
||||
expect(source).toContain('--accent: #61b9ae;')
|
||||
expect(source).toContain('--accent: #d98568;')
|
||||
expect(source).toMatch(/\.mode-segmented button\[data-mode="auto"\]\.active\s*\{[^}]*background:\s*var\(--accent-soft\);/s)
|
||||
expect(source).toMatch(/\.composer-attachment-button\s*\{[^}]*background:\s*var\(--surface-hover\);/s)
|
||||
expect(source).toMatch(/\.composer-skill-button\s*\{[^}]*background:\s*var\(--accent-soft\);/s)
|
||||
expect(source).toMatch(/:root\[data-theme="dark"\] \.send-button\s*\{[^}]*background:\s*var\(--accent\);/s)
|
||||
expect(source).toContain(':root[data-theme="dark"] img[src$="/hermes.svg"] {')
|
||||
expect(source).toContain('hue-rotate(124deg)')
|
||||
expect(source).toMatch(/\.composer-attachment-button\s*\{[^}]*background:\s*transparent;/s)
|
||||
expect(source).toMatch(/\.composer-skill-button\s*\{[^}]*background:\s*transparent;/s)
|
||||
expect(source).toMatch(/:root\[data-theme="dark"\] \.send-button\s*\{[^}]*background:\s*transparent;/s)
|
||||
expect(source).not.toContain(':root[data-theme="dark"] img[src$="/hermes.svg"] {')
|
||||
expect(source).toMatch(/\.target-chip\s*\{[^}]*margin-left:\s*-19px;/s)
|
||||
expect(source).toMatch(/\.target-row\s*\{[^}]*background:\s*transparent;/s)
|
||||
expect(source).toMatch(/\.agent-card\s*\{[^}]*border:\s*0;[^}]*background:\s*color-mix/s)
|
||||
expect(source).toMatch(/\.agent-capability-list > span\s*\{[^}]*border:\s*0;/s)
|
||||
expect(source).toMatch(/\.settings-agent-card\.focused\s*\{[^}]*box-shadow:\s*none;/s)
|
||||
expect(source).not.toContain('img[src$="/hermes.svg"],\n:root[data-theme="dark"] img[src$="/opencode.svg"]')
|
||||
expect(appSource).toContain('composer-attachment-button')
|
||||
expect(appSource).toContain('composer-skill-button')
|
||||
expect(appSource).toContain("modal === 'unlimited-confirm'")
|
||||
expect(appSource).not.toContain('window.confirm')
|
||||
})
|
||||
|
||||
it('keeps the collapsed mobile sidebar to the expandable brand row', () => {
|
||||
@@ -1068,6 +1241,7 @@ describe('RoundRelay workbench', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(bridge.localAgentProvider.save).toHaveBeenCalledWith('hermes', {
|
||||
preset: 'official',
|
||||
provider: 'Local gateway',
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
model: 'roundrelay-model',
|
||||
@@ -1195,16 +1369,17 @@ describe('RoundRelay workbench', () => {
|
||||
await range.setValue(10)
|
||||
expect(wrapper.get('.round-settings-popover output').text()).toBe('10 rounds')
|
||||
await range.setValue(6)
|
||||
const confirmUnlimited = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
await wrapper.get('.round-unlimited-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('consume more Provider quota')
|
||||
expect(wrapper.find('.round-range-input').exists()).toBe(true)
|
||||
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.round-range-input').exists()).toBe(false)
|
||||
expect(wrapper.get('.round-settings-trigger').text()).toContain('Unlimited')
|
||||
await wrapper.get('.round-bounded-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.round-range-input').exists()).toBe(true)
|
||||
confirmUnlimited.mockRestore()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.round-settings-popover').exists()).toBe(false)
|
||||
@@ -1259,8 +1434,8 @@ describe('RoundRelay workbench', () => {
|
||||
|
||||
await wrapper.get('.conversation-link').trigger('click')
|
||||
await wrapper.get('.round-settings-trigger').trigger('click')
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
await wrapper.get('.round-unlimited-button').trigger('click')
|
||||
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
|
||||
await wrapper.get('.composer-box textarea').setValue('Continue until consensus')
|
||||
await wrapper.get('.send-button').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -2174,6 +2349,39 @@ describe('RoundRelay workbench', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the attachment action clickable when image support is unavailable', async () => {
|
||||
const { wrapper, bridge } = await mountApp(({ state }) => {
|
||||
state.agents.push({
|
||||
kind: 'workbuddy',
|
||||
installed: true,
|
||||
available: true,
|
||||
credentialState: 'ready',
|
||||
version: '1.0.0',
|
||||
})
|
||||
state.groups.push({
|
||||
id: 'direct-workbuddy',
|
||||
conversationType: 'direct',
|
||||
directAgentKind: 'workbuddy',
|
||||
name: 'WorkBuddy',
|
||||
agentKinds: ['workbuddy'],
|
||||
workdir: '/tmp/roundrelay-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-07-29T08:00:00Z',
|
||||
updatedAt: '2026-07-29T08:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
await wrapper.get('.direct-session-open').trigger('click')
|
||||
const attachmentButton = wrapper.get('.composer-attachment-button')
|
||||
expect(attachmentButton.attributes()).not.toHaveProperty('disabled')
|
||||
await attachmentButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bridge.localAttachments.pickImages).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.toast-message').text()).toContain('does not support image attachments')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('discards unsent draft images when switching conversations', async () => {
|
||||
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
|
||||
state.groups.push(
|
||||
@@ -2625,6 +2833,8 @@ describe('RoundRelay workbench', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
|
||||
expect(source).toMatch(/\.direct-session-list::before/)
|
||||
expect(source).toMatch(/\.direct-session-list > \.sidebar-more-button::before/)
|
||||
expect(source).toMatch(/\.direct-session-list > :last-child::before\s*\{[^}]*border-bottom-left-radius:\s*5px;/s)
|
||||
expect(source).toMatch(/\.direct-session-list::before\s*\{[^}]*bottom:\s*19px;/s)
|
||||
expect(source).not.toMatch(/\.group-conversation-list::before/)
|
||||
expect(source).not.toMatch(/\.group-conversation-row::before/)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('renderer security policy', () => {
|
||||
expect(hermesSource).not.toMatch(/<(rect|image)\b/)
|
||||
expect(AGENTS.find(agent => agent.kind === 'codex')?.logo).toBe('./agent-logos/codex.svg')
|
||||
expect(AGENTS.find(agent => agent.kind === 'hermes')?.logo).toBe('./agent-logos/hermes.svg')
|
||||
expect(AGENTS.find(agent => agent.kind === 'hermes')?.darkLogo).toBe('./agent-logos/hermes.png')
|
||||
expect(AGENTS.find(agent => agent.kind === 'openclaw')?.logo).toBe('./agent-logos/openclaw-transparent.png')
|
||||
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/App.vue'), 'utf8')
|
||||
|
||||
@@ -4,7 +4,14 @@ export function publicAsset(path) {
|
||||
|
||||
export const AGENTS = Object.freeze([
|
||||
{ kind: 'codex', label: 'Codex', logo: publicAsset('agent-logos/codex.svg'), providerMode: 'responses', imageLimit: 4 },
|
||||
{ kind: 'hermes', label: 'Hermes', logo: publicAsset('agent-logos/hermes.svg'), providerMode: 'compatible', imageLimit: 1 },
|
||||
{
|
||||
kind: 'hermes',
|
||||
label: 'Hermes',
|
||||
logo: publicAsset('agent-logos/hermes.svg'),
|
||||
darkLogo: publicAsset('agent-logos/hermes.png'),
|
||||
providerMode: 'compatible',
|
||||
imageLimit: 1,
|
||||
},
|
||||
{ kind: 'openclaw', label: 'OpenClaw', logo: publicAsset('agent-logos/openclaw-transparent.png'), providerMode: 'compatible', imageLimit: 0 },
|
||||
{ kind: 'workbuddy', label: 'WorkBuddy', logo: publicAsset('agent-logos/workbuddy.png'), providerMode: 'experimental', imageLimit: 0 },
|
||||
{ kind: 'kimi', label: 'Kimi Code', logo: publicAsset('agent-logos/kimi.png'), providerMode: 'native', imageLimit: 0 },
|
||||
@@ -25,6 +32,7 @@ export function agentLabel(kind) {
|
||||
return agentProfile(kind).label
|
||||
}
|
||||
|
||||
export function agentLogo(kind) {
|
||||
return agentProfile(kind).logo
|
||||
export function agentLogo(kind, displayTheme = '') {
|
||||
const profile = agentProfile(kind)
|
||||
return displayTheme === 'dark' && profile.darkLogo ? profile.darkLogo : profile.logo
|
||||
}
|
||||
@@ -57,7 +57,9 @@ const messages = {
|
||||
'home.title': 'Local Agents',
|
||||
'home.subtitle': 'Agents installed or available for installation on this computer.',
|
||||
'home.dashboardTitle': 'Meldwork workspace',
|
||||
'home.dashboardSubtitle': 'See what is ready, resume a conversation, or start a new group task.',
|
||||
'home.dashboardSubtitle': 'Resume a local task or start a focused conversation with an Agent.',
|
||||
'home.workspaceSummary': '{agents} Agents ready, {conversations} local conversations',
|
||||
'home.setupNeeded': 'Local setup needs attention',
|
||||
'home.overview': 'Workspace overview',
|
||||
'home.readyAgents': 'Ready Agents',
|
||||
'home.installedAgents': '{count} installed locally',
|
||||
@@ -67,11 +69,11 @@ const messages = {
|
||||
'home.knowledgeTotal': '{count} local sources',
|
||||
'home.conversations': 'Conversations',
|
||||
'home.activeRuns': '{count} active runs',
|
||||
'home.recentTitle': 'Recent conversations',
|
||||
'home.recentSubtitle': 'Pick up where the local workspace left off.',
|
||||
'home.recentTitle': 'Continue working',
|
||||
'home.recentSubtitle': 'Open a recent conversation and continue from its local context.',
|
||||
'home.recentEmpty': 'Create a group or direct chat to start building your local history.',
|
||||
'home.agentStatusTitle': 'Ready to work',
|
||||
'home.agentStatusSubtitle': 'Local Agents available for a direct chat.',
|
||||
'home.agentStatusTitle': 'Start with an Agent',
|
||||
'home.agentStatusSubtitle': 'Open a focused direct conversation without creating a group.',
|
||||
'home.noReadyAgents': 'No ready Agent has been detected yet.',
|
||||
'home.openSettings': 'Open Agent settings',
|
||||
'home.refresh': 'Detect again',
|
||||
@@ -199,6 +201,7 @@ const messages = {
|
||||
'composer.maxRounds': 'Maximum rounds',
|
||||
'composer.unlimitedRounds': 'Unlimited',
|
||||
'composer.unlimitedHint': 'The group will continue until consensus, timeout, or manual stop.',
|
||||
'composer.unlimitedConfirmTitle': 'Continue with unlimited discussion?',
|
||||
'composer.unlimitedConfirm': 'Unlimited automatic discussion can run for a long time and consume more Provider quota. Continue?',
|
||||
'composer.useBoundedRounds': 'Use bounded rounds',
|
||||
'composer.addTarget': 'Include {agent}',
|
||||
@@ -254,11 +257,12 @@ const messages = {
|
||||
'systemSettings.providerForAgent': 'Provider',
|
||||
'systemSettings.openAgentDetail': 'View {agent} details',
|
||||
'systemSettings.openAgentDetailDefault': 'Agent details',
|
||||
'knowledgeBase.title': 'Knowledge sources',
|
||||
'knowledgeBase.title': 'Knowledge bases',
|
||||
'knowledgeBase.readyCount': '{ready} ready, {total} total',
|
||||
'knowledgeBase.subtitle': 'Connect Feishu, DingTalk, or Obsidian as local knowledge inputs.',
|
||||
'knowledgeBase.group.local': 'Local sources',
|
||||
'knowledgeBase.subtitle': 'Connect local document sources and verify the access available to Meldwork.',
|
||||
'knowledgeBase.group.local': 'Local connections',
|
||||
'knowledgeBase.group.planned': 'Coming soon',
|
||||
'knowledgeBase.group.plannedHint': 'Reference links only. These connectors are not active yet.',
|
||||
'knowledgeBase.refresh': 'Refresh status',
|
||||
'knowledgeBase.source.feishu': 'Feishu Docs',
|
||||
'knowledgeBase.source.dingtalk': 'DingTalk Docs',
|
||||
@@ -274,13 +278,13 @@ const messages = {
|
||||
'knowledgeBase.badge.confluence': 'CF',
|
||||
'knowledgeBase.badge.googledrive': 'GD',
|
||||
'knowledgeBase.badge.sharepoint': 'SP',
|
||||
'knowledgeBase.description.feishu': 'Cloud knowledge in Feishu documents, with local CLI login and permission checks.',
|
||||
'knowledgeBase.description.dingtalk': 'Cloud knowledge in DingTalk documents, with local CLI login and permission checks.',
|
||||
'knowledgeBase.description.obsidian': 'A local Obsidian vault stays on disk and can be selected directly.',
|
||||
'knowledgeBase.description.notion': 'Planned Notion support for pages and databases. Connection is not available in this version.',
|
||||
'knowledgeBase.description.confluence': 'Planned Confluence support for spaces and pages. Connection is not available in this version.',
|
||||
'knowledgeBase.description.googledrive': 'Planned Google Drive support for documents and folders. Connection is not available in this version.',
|
||||
'knowledgeBase.description.sharepoint': 'Planned SharePoint support for sites and document libraries. Connection is not available in this version.',
|
||||
'knowledgeBase.description.feishu': 'Use Feishu documents through the locally installed Lark CLI.',
|
||||
'knowledgeBase.description.dingtalk': 'Use DingTalk documents through the locally installed DWS CLI.',
|
||||
'knowledgeBase.description.obsidian': 'Use a vault selected from this Mac.',
|
||||
'knowledgeBase.description.notion': 'Pages and databases',
|
||||
'knowledgeBase.description.confluence': 'Spaces and pages',
|
||||
'knowledgeBase.description.googledrive': 'Documents and folders',
|
||||
'knowledgeBase.description.sharepoint': 'Sites and document libraries',
|
||||
'knowledgeBase.status.obsidianMissing': 'Obsidian not installed',
|
||||
'knowledgeBase.status.obsidianNeedVault': 'Vault not selected',
|
||||
'knowledgeBase.status.cliMissing': 'CLI not installed',
|
||||
@@ -292,6 +296,9 @@ const messages = {
|
||||
'knowledgeBase.status.error': 'Detection failed',
|
||||
'knowledgeBase.status.ready': 'Ready',
|
||||
'knowledgeBase.status.comingSoon': 'Coming soon',
|
||||
'knowledgeBase.fact.connection': 'Connection',
|
||||
'knowledgeBase.fact.read': 'Read',
|
||||
'knowledgeBase.fact.write': 'Write',
|
||||
'knowledgeBase.tag.mode.cli': 'Official CLI',
|
||||
'knowledgeBase.tag.mode.vault': 'Local vault',
|
||||
'knowledgeBase.tag.mode.oauth': 'OAuth',
|
||||
@@ -329,15 +336,15 @@ const messages = {
|
||||
'knowledgeBase.hint.remoteNeedPermission': 'Grant read/write scopes for the selected workspace or site.',
|
||||
'knowledgeBase.hint.remoteReady': 'This remote knowledge base is connected and ready for local use.',
|
||||
'knowledgeBase.hint.comingSoon': 'This integration is not available yet. The official documentation is provided for reference and does not connect your workspace.',
|
||||
'knowledgeBase.action.installCli': 'Install CLI',
|
||||
'knowledgeBase.action.goLogin': 'Go to login',
|
||||
'knowledgeBase.action.grantPermission': 'Grant permission',
|
||||
'knowledgeBase.action.installCli': 'Open install guide',
|
||||
'knowledgeBase.action.goLogin': 'Open login guide',
|
||||
'knowledgeBase.action.grantPermission': 'Open permission guide',
|
||||
'knowledgeBase.action.recheck': 'Recheck',
|
||||
'knowledgeBase.action.openSetupGuide': 'Open setup guide',
|
||||
'knowledgeBase.action.openInstallPage': 'Open install guide',
|
||||
'knowledgeBase.action.openLoginPage': 'Open login guide',
|
||||
'knowledgeBase.action.openPermissionPage': 'Open permission guide',
|
||||
'knowledgeBase.action.installObsidian': 'Download Obsidian',
|
||||
'knowledgeBase.action.installObsidian': 'Open download page',
|
||||
'knowledgeBase.action.pickDirectory': 'Choose vault directory',
|
||||
'knowledgeBase.action.changeDirectory': 'Change directory',
|
||||
'knowledgeBase.action.viewDocumentation': 'View official docs',
|
||||
@@ -373,6 +380,10 @@ const messages = {
|
||||
'provider.title': 'Agent Provider',
|
||||
'provider.subtitle': 'Each Agent keeps its own Provider profile, with official, OpenRouter, and custom options.',
|
||||
'provider.configuredCount': '{configured} ready of {total}',
|
||||
'provider.activeProfile': '{provider} active',
|
||||
'provider.active': 'Active',
|
||||
'provider.useProfile': 'Use {provider}',
|
||||
'provider.switchUnavailable': 'Provider switching is unavailable in this desktop bridge.',
|
||||
'provider.capability.compatible': 'OpenAI-compatible endpoints',
|
||||
'provider.capability.experimental': 'OpenAI-compatible, experimental',
|
||||
'provider.capability.responses': 'Responses API only',
|
||||
@@ -380,16 +391,12 @@ const messages = {
|
||||
'provider.capability.native': 'CLI-native configuration',
|
||||
'provider.nativeTitle': 'Use the Agent CLI configuration',
|
||||
'provider.nativeBody': 'This Agent owns its Provider settings locally. Keep the official source selected and manage credentials in the CLI configuration shown below.',
|
||||
'provider.nativeConfiguredHint': 'Native CLI configuration is selected',
|
||||
'provider.nativeOverrideTitle': 'Meldwork override is still active',
|
||||
'provider.nativeOverrideBody': 'Selecting Official does not remove the saved override. Remove it below before this Agent can return to its native CLI configuration.',
|
||||
'provider.nativeOverrideFooterHint': 'Remove the saved override before the native CLI configuration can take effect.',
|
||||
'provider.nativeOverrideBody': 'Selecting Official keeps the saved override active. Complete the two-step removal in this warning before the Agent can return to its native CLI configuration.',
|
||||
'provider.nativeStatusCheckingTitle': 'Checking saved overrides',
|
||||
'provider.nativeStatusCheckingBody': 'Meldwork is checking whether this Agent has a saved Provider override.',
|
||||
'provider.nativeStatusErrorTitle': 'Provider status could not be verified',
|
||||
'provider.nativeStatusErrorBody': 'Meldwork could not verify whether a saved override is active. Native CLI configuration is not assumed until this check succeeds.',
|
||||
'provider.nativeStatusErrorFooterHint': 'Provider status is unavailable. Reopen this Agent or retry after local credential access is restored.',
|
||||
'provider.nativeDetectedHint': 'A usable native CLI credential was detected. Saving a profile below creates a Meldwork-managed override for this Agent.',
|
||||
'provider.nativeStatusErrorBody': 'Meldwork could not verify whether a saved override is active. Retry the check before relying on native CLI configuration.',
|
||||
'provider.agentTitle': '{agent} Provider',
|
||||
'provider.agentSpecificHint': 'This connection is used only by the selected Agent. Other Agents keep their own Provider profile or native sign-in.',
|
||||
'provider.source': 'Provider source',
|
||||
@@ -439,6 +446,7 @@ const messages = {
|
||||
'provider.keyRequired': 'Enter an API key.',
|
||||
'common.close': 'Close',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.save': 'Save',
|
||||
'common.saving': 'Saving',
|
||||
'common.loading': 'Loading',
|
||||
@@ -555,7 +563,9 @@ const messages = {
|
||||
'home.title': '本地 Agent',
|
||||
'home.subtitle': '这台电脑上已安装或可安装的 Agent。',
|
||||
'home.dashboardTitle': 'Meldwork 工作台',
|
||||
'home.dashboardSubtitle': '查看当前可用状态,继续最近会话,或开始新的群聊任务。',
|
||||
'home.dashboardSubtitle': '继续本地任务,或直接与一个 Agent 开始专注对话。',
|
||||
'home.workspaceSummary': '{agents} 个 Agent 可用,{conversations} 个本地会话',
|
||||
'home.setupNeeded': '本地配置需要处理',
|
||||
'home.overview': '工作台概览',
|
||||
'home.readyAgents': '可用 Agent',
|
||||
'home.installedAgents': '本机已安装 {count} 个',
|
||||
@@ -565,11 +575,11 @@ const messages = {
|
||||
'home.knowledgeTotal': '共 {count} 个本地来源',
|
||||
'home.conversations': '会话',
|
||||
'home.activeRuns': '{count} 个正在运行',
|
||||
'home.recentTitle': '最近会话',
|
||||
'home.recentSubtitle': '继续本地工作台里最近进行的工作。',
|
||||
'home.recentTitle': '继续工作',
|
||||
'home.recentSubtitle': '打开最近会话,并沿用已有的本地上下文。',
|
||||
'home.recentEmpty': '创建群聊或单聊后,最近会话会显示在这里。',
|
||||
'home.agentStatusTitle': '可立即工作',
|
||||
'home.agentStatusSubtitle': '当前可直接发起单聊的本地 Agent。',
|
||||
'home.agentStatusTitle': '从 Agent 开始',
|
||||
'home.agentStatusSubtitle': '无需创建群聊,直接开始一段专注的单聊。',
|
||||
'home.noReadyAgents': '暂时没有检测到可用的 Agent。',
|
||||
'home.openSettings': '打开 Agent 设置',
|
||||
'home.refresh': '重新检测',
|
||||
@@ -697,6 +707,7 @@ const messages = {
|
||||
'composer.maxRounds': '最大轮数',
|
||||
'composer.unlimitedRounds': '不限制',
|
||||
'composer.unlimitedHint': '群聊会持续到达成共识、超时或手动停止。',
|
||||
'composer.unlimitedConfirmTitle': '继续使用不限制轮数吗?',
|
||||
'composer.unlimitedConfirm': '不限制的自动讨论可能运行较久并消耗更多 Provider 额度,是否继续?',
|
||||
'composer.useBoundedRounds': '改回固定轮数',
|
||||
'composer.addTarget': '加入 {agent}',
|
||||
@@ -752,11 +763,12 @@ const messages = {
|
||||
'systemSettings.providerForAgent': 'Provider',
|
||||
'systemSettings.openAgentDetail': '查看 {agent} 详情',
|
||||
'systemSettings.openAgentDetailDefault': 'Agent 详情',
|
||||
'knowledgeBase.title': '知识源',
|
||||
'knowledgeBase.title': '知识库',
|
||||
'knowledgeBase.readyCount': '已就绪 {ready} / 共 {total}',
|
||||
'knowledgeBase.subtitle': '连接飞书、钉钉或 Obsidian 作为本地知识输入。',
|
||||
'knowledgeBase.group.local': '本地来源',
|
||||
'knowledgeBase.subtitle': '连接本地文档源,并确认 Meldwork 当前具备的访问能力。',
|
||||
'knowledgeBase.group.local': '本机接入',
|
||||
'knowledgeBase.group.planned': '即将支持',
|
||||
'knowledgeBase.group.plannedHint': '仅提供官方资料入口,当前版本尚未开放连接。',
|
||||
'knowledgeBase.refresh': '刷新状态',
|
||||
'knowledgeBase.source.feishu': '飞书文档',
|
||||
'knowledgeBase.source.dingtalk': '钉钉文档',
|
||||
@@ -772,13 +784,13 @@ const messages = {
|
||||
'knowledgeBase.badge.confluence': 'CF',
|
||||
'knowledgeBase.badge.googledrive': 'GD',
|
||||
'knowledgeBase.badge.sharepoint': 'SP',
|
||||
'knowledgeBase.description.feishu': '飞书文档里的云端知识,依赖本地 CLI 的登录与权限检测。',
|
||||
'knowledgeBase.description.dingtalk': '钉钉文档里的云端知识,依赖本地 CLI 的登录与权限检测。',
|
||||
'knowledgeBase.description.obsidian': 'Obsidian 的本地知识库保留在磁盘上,可以直接选择目录。',
|
||||
'knowledgeBase.description.notion': '计划接入 Notion 页面和数据库,当前版本暂不支持连接。',
|
||||
'knowledgeBase.description.confluence': '计划接入 Confluence 空间和页面,当前版本暂不支持连接。',
|
||||
'knowledgeBase.description.googledrive': '计划接入 Google Drive 文档和文件夹,当前版本暂不支持连接。',
|
||||
'knowledgeBase.description.sharepoint': '计划接入 SharePoint 站点和文档库,当前版本暂不支持连接。',
|
||||
'knowledgeBase.description.feishu': '通过本机 Lark CLI 使用飞书文档。',
|
||||
'knowledgeBase.description.dingtalk': '通过本机 DWS CLI 使用钉钉文档。',
|
||||
'knowledgeBase.description.obsidian': '选择这台 Mac 上的本地库目录。',
|
||||
'knowledgeBase.description.notion': '页面和数据库',
|
||||
'knowledgeBase.description.confluence': '空间和页面',
|
||||
'knowledgeBase.description.googledrive': '文档和文件夹',
|
||||
'knowledgeBase.description.sharepoint': '站点和文档库',
|
||||
'knowledgeBase.status.obsidianMissing': 'Obsidian 未安装',
|
||||
'knowledgeBase.status.obsidianNeedVault': '尚未选择库目录',
|
||||
'knowledgeBase.status.cliMissing': 'CLI 未安装',
|
||||
@@ -790,6 +802,9 @@ const messages = {
|
||||
'knowledgeBase.status.error': '检测失败',
|
||||
'knowledgeBase.status.ready': '已就绪',
|
||||
'knowledgeBase.status.comingSoon': '即将支持',
|
||||
'knowledgeBase.fact.connection': '接入方式',
|
||||
'knowledgeBase.fact.read': '读取',
|
||||
'knowledgeBase.fact.write': '写入',
|
||||
'knowledgeBase.tag.mode.cli': '官方 CLI',
|
||||
'knowledgeBase.tag.mode.vault': '本地库',
|
||||
'knowledgeBase.tag.mode.oauth': 'OAuth',
|
||||
@@ -827,15 +842,15 @@ const messages = {
|
||||
'knowledgeBase.hint.remoteNeedPermission': '为选中的工作区或站点授予读写权限范围。',
|
||||
'knowledgeBase.hint.remoteReady': '这个远程知识源已连接,可在本地使用。',
|
||||
'knowledgeBase.hint.comingSoon': '当前版本尚未提供该连接能力;官方文档仅供参考,打开后不会连接你的工作区。',
|
||||
'knowledgeBase.action.installCli': '安装 CLI',
|
||||
'knowledgeBase.action.goLogin': '去登录',
|
||||
'knowledgeBase.action.grantPermission': '去授权',
|
||||
'knowledgeBase.action.installCli': '打开安装指引',
|
||||
'knowledgeBase.action.goLogin': '打开登录指引',
|
||||
'knowledgeBase.action.grantPermission': '打开授权指引',
|
||||
'knowledgeBase.action.recheck': '重新检测',
|
||||
'knowledgeBase.action.openSetupGuide': '打开接入指引',
|
||||
'knowledgeBase.action.openInstallPage': '打开安装指引',
|
||||
'knowledgeBase.action.openLoginPage': '打开登录指引',
|
||||
'knowledgeBase.action.openPermissionPage': '打开授权指引',
|
||||
'knowledgeBase.action.installObsidian': '下载 Obsidian',
|
||||
'knowledgeBase.action.installObsidian': '打开下载页',
|
||||
'knowledgeBase.action.pickDirectory': '选择库目录',
|
||||
'knowledgeBase.action.changeDirectory': '更换目录',
|
||||
'knowledgeBase.action.viewDocumentation': '查看官方文档',
|
||||
@@ -871,6 +886,10 @@ const messages = {
|
||||
'provider.title': 'Agent Provider',
|
||||
'provider.subtitle': '每个 Agent 都有独立 Provider 档案,可选择官方、OpenRouter 或自定义配置。',
|
||||
'provider.configuredCount': '已就绪 {configured} / 共 {total}',
|
||||
'provider.activeProfile': '当前使用 {provider}',
|
||||
'provider.active': '当前使用',
|
||||
'provider.useProfile': '使用 {provider}',
|
||||
'provider.switchUnavailable': '当前桌面桥接不支持切换 Provider。',
|
||||
'provider.capability.compatible': '支持 OpenAI 兼容端点',
|
||||
'provider.capability.experimental': '支持 OpenAI 兼容端点,实验性',
|
||||
'provider.capability.responses': '仅支持 Responses API',
|
||||
@@ -878,16 +897,12 @@ const messages = {
|
||||
'provider.capability.native': '使用 CLI 原生配置',
|
||||
'provider.nativeTitle': '使用 Agent CLI 的原生配置',
|
||||
'provider.nativeBody': '这个 Agent 自己管理本地 Provider 设置。保持官方来源,并按下方显示的 CLI 配置说明管理凭据。',
|
||||
'provider.nativeConfiguredHint': '当前使用 CLI 原生配置',
|
||||
'provider.nativeOverrideTitle': 'Meldwork 覆盖配置仍在生效',
|
||||
'provider.nativeOverrideBody': '选择“官方”不会删除已保存的覆盖配置。必须先在下方移除覆盖配置,这个 Agent 才会回到 CLI 原生配置。',
|
||||
'provider.nativeOverrideFooterHint': '先移除已保存的覆盖配置,CLI 原生配置才会生效。',
|
||||
'provider.nativeOverrideBody': '选择“官方”不会停用已保存的覆盖配置。请在此警告中完成两步移除,之后该 Agent 才会回到 CLI 原生配置。',
|
||||
'provider.nativeStatusCheckingTitle': '正在检查覆盖配置',
|
||||
'provider.nativeStatusCheckingBody': 'Meldwork 正在确认这个 Agent 是否存在已保存的 Provider 覆盖配置。',
|
||||
'provider.nativeStatusErrorTitle': '无法验证 Provider 状态',
|
||||
'provider.nativeStatusErrorBody': 'Meldwork 无法确认已保存的覆盖配置是否仍在生效。在检查成功前,不会假定已经切回 CLI 原生配置。',
|
||||
'provider.nativeStatusErrorFooterHint': 'Provider 状态当前不可用,请重新打开该 Agent,或在本地凭据恢复访问后重试。',
|
||||
'provider.nativeDetectedHint': '已检测到可用的 CLI 原生凭据;在下方保存后,将为当前 Agent 创建由 Meldwork 管理的独立覆盖配置。',
|
||||
'provider.nativeStatusErrorBody': 'Meldwork 无法确认已保存的覆盖配置是否仍在生效。请先重试检查,再依赖 CLI 原生配置。',
|
||||
'provider.agentTitle': '{agent} Provider',
|
||||
'provider.agentSpecificHint': '该连接仅供当前 Agent 使用,其他 Agent 继续使用各自的 Provider 档案或原生登录。',
|
||||
'provider.source': 'Provider 来源',
|
||||
@@ -937,6 +952,7 @@ const messages = {
|
||||
'provider.keyRequired': '请输入 API Key。',
|
||||
'common.close': '关闭',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确认',
|
||||
'common.save': '保存',
|
||||
'common.saving': '保存中',
|
||||
'common.loading': '加载中',
|
||||
|
||||